diff --git a/apps/api/src/lib/storage/serialization.ts b/apps/api/src/lib/storage/serialization.ts index 5d40810..dd11697 100644 --- a/apps/api/src/lib/storage/serialization.ts +++ b/apps/api/src/lib/storage/serialization.ts @@ -1,6 +1,7 @@ import type { AnalyticsSummary, ExecutionFallbackReason, + LatencyBucket, PaymentAttempt, QueryMode, UsageEvent @@ -14,6 +15,18 @@ function emptySpendByCategory(): Record { return { search: 0, news: 0, scrape: 0 }; } +function emptyLatencyBuckets(): Record { + return { "<1s": 0, "1-3s": 0, "3-10s": 0, ">10s": 0, unknown: 0 }; +} + +function classifyLatency(latencyMs: number): LatencyBucket { + if (latencyMs <= 0) return "unknown"; + if (latencyMs < 1000) return "<1s"; + if (latencyMs < 3000) return "1-3s"; + if (latencyMs < 10000) return "3-10s"; + return ">10s"; +} + function emptyExecutionSummary(): NonNullable { return { totalExecutions: 0, @@ -112,6 +125,12 @@ export function buildAnalyticsSummary( emptyExecutionSummary() ); + const latencyBuckets = usage.reduce>((acc, event) => { + const bucket = classifyLatency(event.latencyMs); + acc[bucket] += 1; + return acc; + }, emptyLatencyBuckets()); + const recentUsageLimit = options?.recentUsageLimit ?? DEFAULT_RECENT_LIMIT; const recentPaymentLimit = options?.recentPaymentLimit ?? DEFAULT_RECENT_LIMIT; @@ -125,6 +144,7 @@ export function buildAnalyticsSummary( settledSpendByCategory, demoSpendByCategory, executionSummary, + latencyBuckets, recentTransactions: payments.slice(0, recentPaymentLimit), recentUsage: usage.slice(0, recentUsageLimit) }; diff --git a/apps/api/src/routes/public.test.ts b/apps/api/src/routes/public.test.ts index a9262cf..3c86b42 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -259,4 +259,67 @@ describe("public routes", () => { expect(analyticsResponse.body.totalSpendUsd).toBe(0.01); expect(analyticsResponse.body.spendByCategory.search).toBe(0.01); }); + + it("returns latency bucket counts that classify queries by execution time", async () => { + const app = await createPublicApp(); + const { saveUsageEvent } = await import("../lib/persistence.js"); + + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_latency_fast", + traceId: "trace_fast", + createdAt: "2026-06-21T10:00:00.000Z", + latencyMs: 500 + }) + ); + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_latency_medium", + traceId: "trace_medium", + createdAt: "2026-06-21T10:00:01.000Z", + latencyMs: 2000 + }) + ); + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_latency_slow", + traceId: "trace_slow", + createdAt: "2026-06-21T10:00:02.000Z", + latencyMs: 5000 + }) + ); + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_latency_very_slow", + traceId: "trace_very_slow", + createdAt: "2026-06-21T10:00:03.000Z", + latencyMs: 15000 + }) + ); + await saveUsageEvent( + buildTestUsageEvent({ + id: "use_latency_unknown", + traceId: "trace_unknown", + createdAt: "2026-06-21T10:00:04.000Z", + latencyMs: 0 + }) + ); + + const analyticsResponse = await request(app).get("/api/analytics"); + + expect(analyticsResponse.status).toBe(200); + expect(analyticsResponse.body.latencyBuckets).toEqual({ + "<1s": 1, + "1-3s": 1, + "3-10s": 1, + ">10s": 1, + unknown: 1 + }); + expect(analyticsResponse.body.totalQueries).toBe(5); + expect(analyticsResponse.body.latencyBuckets["<1s"]).toBe(1); + expect(analyticsResponse.body.latencyBuckets["1-3s"]).toBe(1); + expect(analyticsResponse.body.latencyBuckets["3-10s"]).toBe(1); + expect(analyticsResponse.body.latencyBuckets[">10s"]).toBe(1); + expect(analyticsResponse.body.latencyBuckets.unknown).toBe(1); + }); }); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index b83c0a6..64d8215 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,4 @@ -import type { ProviderDefinition, QueryMode, QueryResult } from "@query402/shared"; +import type { LatencyBucket, ProviderDefinition, QueryMode, QueryResult } from "@query402/shared"; export interface PaymentProofLinks { transaction: string; @@ -45,6 +45,7 @@ export interface AnalyticsResponse { fallbackByCategory: Record; fallbackReasonCounts: Record; }; + latencyBuckets: Record; recentTransactions: Array<{ id: string; amountUsd: number; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ecc0687..2243b99 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -11,6 +11,8 @@ export type ExecutionFallbackReason = export type CircuitBreakerState = "closed" | "half-open" | "open"; export type PaymentSource = "sponsored" | "wallet" | "demo"; +export type LatencyBucket = "<1s" | "1-3s" | "3-10s" | ">10s" | "unknown"; + export interface ProviderExecutionMetadata { providerId: string; source: SourceType; @@ -122,6 +124,7 @@ export interface AnalyticsSummary { fallbackByCategory: Record; fallbackReasonCounts: Record; }; + latencyBuckets: Record; recentTransactions: PaymentAttempt[]; recentUsage: UsageEvent[]; }