diff --git a/app/Services/PrometheusMetricsSummary.php b/app/Services/PrometheusMetricsSummary.php index 1bd36d124..f96802831 100644 --- a/app/Services/PrometheusMetricsSummary.php +++ b/app/Services/PrometheusMetricsSummary.php @@ -400,9 +400,12 @@ private function workerRows(string $namespace, CarbonInterface $now, array $queu private function selectLatencyBuckets($query, string $durationExpr, string $durationAvailableSql): void { + // SQLite duration expressions have no affinity, while PDO binds floats as text. + $bound = $query->getConnection()->getDriverName() === 'sqlite' ? 'CAST(? AS REAL)' : '?'; + foreach (self::LATENCY_BUCKETS_SECONDS as $index => $bucket) { $query->selectRaw( - "SUM(CASE WHEN {$durationAvailableSql} AND {$durationExpr} <= ? THEN 1 ELSE 0 END) as bucket_{$index}", + "SUM(CASE WHEN {$durationAvailableSql} AND {$durationExpr} <= {$bound} THEN 1 ELSE 0 END) as bucket_{$index}", [$bucket], ); } diff --git a/tests/Feature/SystemPrometheusMetricsTest.php b/tests/Feature/SystemPrometheusMetricsTest.php index 626b17f8c..239be98e6 100644 --- a/tests/Feature/SystemPrometheusMetricsTest.php +++ b/tests/Feature/SystemPrometheusMetricsTest.php @@ -33,6 +33,43 @@ protected function tearDown(): void parent::tearDown(); } + public function test_latency_buckets_exclude_durations_above_the_bound(): void + { + $run = $this->workflowRun( + workflowId: 'histogram-run', + workflowType: 'HistogramWorkflow', + queue: 'histogram', + status: 'completed', + statusBucket: 'completed', + durationMs: 2000, + ); + $this->activity($run, 'HistogramActivity', 'histogram', 'completed', 1500); + + $response = $this->getJson( + '/api/system/prometheus-metrics', + $this->controlPlaneHeadersWithWorkerProtocol(), + ); + + $response->assertOk() + ->assertJsonPath('series.workflows.0.latency_seconds.count', 1) + ->assertJsonPath('series.activities.0.latency_seconds.count', 1); + + $expected = [ + '0.1' => 0, + '0.5' => 0, + '1' => 0, + '2.5' => 1, + '5' => 1, + '10' => 1, + '30' => 1, + '60' => 1, + '300' => 1, + '900' => 1, + ]; + $this->assertSame($expected, $response->json('series.workflows.0.latency_seconds.buckets')); + $this->assertSame($expected, $response->json('series.activities.0.latency_seconds.buckets')); + } + public function test_prometheus_metrics_summary_is_granular_by_queue_workflow_and_activity_type(): void { $runA = $this->workflowRun( @@ -554,6 +591,8 @@ private function workflowRun( private function activity(string $runId, string $activityType, string $queue, string $status, int $durationMs): void { $startedAt = now()->subSeconds(10); + // Query-builder DateTime bindings would discard the fixture's fractional second. + $closedAt = $startedAt->copy()->addMilliseconds($durationMs)->format('Y-m-d H:i:s.u'); DB::table('activity_executions')->insert([ 'id' => (string) Str::ulid(), @@ -564,9 +603,9 @@ private function activity(string $runId, string $activityType, string $queue, st 'status' => $status, 'queue' => $queue, 'started_at' => $startedAt, - 'closed_at' => $startedAt->copy()->addMilliseconds($durationMs), + 'closed_at' => $closedAt, 'created_at' => $startedAt, - 'updated_at' => $startedAt->copy()->addMilliseconds($durationMs), + 'updated_at' => $closedAt, ]); }