From a66bfdae83bf221b9fbd9e143133161b82dd2ff0 Mon Sep 17 00:00:00 2001 From: olajide peter tosin Date: Mon, 29 Jun 2026 23:30:36 +0000 Subject: [PATCH 1/2] feat(analytics): add latency histogram buckets to analytics summary Add latencyBuckets field to AnalyticsSummary showing query execution time distribution across <1s, 1-3s, 3-10s, >10s, and unknown buckets. Closes #89 --- apps/api/src/lib/storage/serialization.ts | 21 ++++++- apps/api/src/routes/public.test.ts | 70 +++++++++++++++++++++++ apps/web/src/types.ts | 3 +- packages/shared/src/types.ts | 3 + 4 files changed, 95 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/storage/serialization.ts b/apps/api/src/lib/storage/serialization.ts index 3238781..57210f9 100644 --- a/apps/api/src/lib/storage/serialization.ts +++ b/apps/api/src/lib/storage/serialization.ts @@ -1,4 +1,4 @@ -import type { AnalyticsSummary, PaymentAttempt, QueryMode, UsageEvent } from "@query402/shared"; +import type { AnalyticsSummary, LatencyBucket, PaymentAttempt, QueryMode, UsageEvent } from "@query402/shared"; import { DEFAULT_RECENT_LIMIT } from "./constants.js"; import type { AnalyticsQueryOptions } from "./types.js"; @@ -6,6 +6,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"; +} + export function buildAnalyticsSummary( usage: UsageEvent[], payments: PaymentAttempt[], @@ -50,6 +62,12 @@ export function buildAnalyticsSummary( (spendByCategory.search + spendByCategory.news + spendByCategory.scrape).toFixed(6) ); + 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; @@ -62,6 +80,7 @@ export function buildAnalyticsSummary( spendByCategory, settledSpendByCategory, demoSpendByCategory, + 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 771f747..3c105e8 100644 --- a/apps/api/src/routes/public.test.ts +++ b/apps/api/src/routes/public.test.ts @@ -82,6 +82,13 @@ describe("public routes", () => { news: 0, scrape: 0 }, + latencyBuckets: { + "<1s": 0, + "1-3s": 0, + "3-10s": 0, + ">10s": 0, + unknown: 0 + }, recentUsage: [], recentTransactions: [] }); @@ -117,4 +124,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 35dbdbf..df31c3e 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 PaidQueryResponse { payment: { @@ -13,6 +13,7 @@ export interface AnalyticsResponse { totalQueries: number; totalSpendUsd: number; spendByCategory: Record; + latencyBuckets: Record; recentTransactions: Array<{ id: string; amountUsd: number; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 587947f..c1a8779 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -83,6 +83,8 @@ export interface PaymentAttempt { sponsorPublicKey?: string; } +export type LatencyBucket = "<1s" | "1-3s" | "3-10s" | ">10s" | "unknown"; + export interface AnalyticsSummary { totalQueries: number; totalSpendUsd: number; @@ -92,6 +94,7 @@ export interface AnalyticsSummary { spendByCategory: Record; settledSpendByCategory: Record; demoSpendByCategory: Record; + latencyBuckets: Record; recentTransactions: PaymentAttempt[]; recentUsage: UsageEvent[]; } From 9d9002cb0d439a00b70dfe1f05675a5c1150601f Mon Sep 17 00:00:00 2001 From: olajide peter tosin Date: Thu, 2 Jul 2026 10:08:10 +0000 Subject: [PATCH 2/2] fix: re-add latency histogram buckets lost during merge conflict resolution The merge with main dropped the latencyBuckets field from AnalyticsSummary during conflict resolution. This restores it alongside the existing executionSummary field so both coexist in the /api/analytics response. --- apps/api/src/lib/storage/serialization.ts | 20 ++++++++++++++++++++ apps/web/src/types.ts | 1 + packages/shared/src/types.ts | 5 +++-- 3 files changed, 24 insertions(+), 2 deletions(-) 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/web/src/types.ts b/apps/web/src/types.ts index 1999f9d..64d8215 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -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 bd0d1ae..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; @@ -103,8 +105,6 @@ export interface PaymentAttempt { sponsorPublicKey?: string; } -export type LatencyBucket = "<1s" | "1-3s" | "3-10s" | ">10s" | "unknown"; - export interface AnalyticsSummary { totalQueries: number; totalSpendUsd: number; @@ -124,6 +124,7 @@ export interface AnalyticsSummary { fallbackByCategory: Record; fallbackReasonCounts: Record; }; + latencyBuckets: Record; recentTransactions: PaymentAttempt[]; recentUsage: UsageEvent[]; }