Skip to content

Commit 35fde64

Browse files
feat(analytics): per-day token breakdown endpoint (#3282) (#3288)
Add dailyBreakdown field to GET /v1/analytics/tokens with from/to query parameter support for time-range filtering. Changes: - MeteringService.getDailyTokenBreakdown() aggregates token counts (input, output, cacheRead, cacheWrite) by YYYY-MM-DD date - /v1/analytics/tokens now accepts ?from=X&to=Y and includes dailyBreakdown array in response - Backward-compatible: existing fields (totalTokens, modelDistribution, dailyCost) unchanged - 10 new tests: 5 unit (MeteringService) + 5 integration (route)
1 parent c4cab08 commit 35fde64

5 files changed

Lines changed: 374 additions & 2 deletions

File tree

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* analytics-tokens-daily-3282.test.ts β€” Tests for per-day token breakdown.
3+
*
4+
* Issue #3282: GET /v1/analytics/tokens now includes dailyBreakdown
5+
* with from/to query parameter support.
6+
*/
7+
8+
import Fastify, { type FastifyInstance } from 'fastify';
9+
import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
10+
11+
import { SYSTEM_TENANT } from '../config.js';
12+
import { registerAnalyticsRoutes } from '../routes/analytics.js';
13+
import type { RouteContext } from '../routes/context.js';
14+
15+
const NOW = new Date('2026-05-13T12:00:00Z').getTime();
16+
const DAY = 86_400_000;
17+
18+
const records = [
19+
{
20+
id: 1, sessionId: 's1', keyId: 'k1',
21+
timestamp: '2026-05-10T10:00:00Z',
22+
eventType: 'message', inputTokens: 100, outputTokens: 50,
23+
cacheCreationTokens: 10, cacheReadTokens: 20,
24+
costUsd: 0.01, model: 'sonnet',
25+
},
26+
{
27+
id: 2, sessionId: 's2', keyId: 'k1',
28+
timestamp: '2026-05-10T14:00:00Z',
29+
eventType: 'message', inputTokens: 200, outputTokens: 100,
30+
cacheCreationTokens: 30, cacheReadTokens: 40,
31+
costUsd: 0.02, model: 'sonnet',
32+
},
33+
{
34+
id: 3, sessionId: 's3', keyId: 'k1',
35+
timestamp: '2026-05-12T10:00:00Z',
36+
eventType: 'message', inputTokens: 300, outputTokens: 150,
37+
cacheCreationTokens: 50, cacheReadTokens: 60,
38+
costUsd: 0.03, model: 'sonnet',
39+
},
40+
];
41+
42+
function mockMetering() {
43+
return {
44+
getDailyTokenBreakdown: vi.fn((options?: { from?: string; to?: string }) => {
45+
let filtered = records;
46+
if (options?.from) filtered = filtered.filter(r => r.timestamp >= options.from!);
47+
if (options?.to) filtered = filtered.filter(r => r.timestamp <= options.to!);
48+
49+
const dayMap = new Map<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }>();
50+
for (const r of filtered) {
51+
const date = r.timestamp.slice(0, 10);
52+
let bucket = dayMap.get(date);
53+
if (!bucket) {
54+
bucket = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
55+
dayMap.set(date, bucket);
56+
}
57+
bucket.inputTokens += r.inputTokens;
58+
bucket.outputTokens += r.outputTokens;
59+
bucket.cacheReadTokens += r.cacheReadTokens;
60+
bucket.cacheWriteTokens += r.cacheCreationTokens;
61+
}
62+
63+
return [...dayMap.entries()]
64+
.map(([date, d]) => ({
65+
date,
66+
inputTokens: d.inputTokens,
67+
outputTokens: d.outputTokens,
68+
cacheReadTokens: d.cacheReadTokens,
69+
cacheWriteTokens: d.cacheWriteTokens,
70+
}))
71+
.sort((a, b) => a.date.localeCompare(b.date));
72+
}),
73+
getUsageSummary: vi.fn(() => ({
74+
totalInputTokens: 600, totalOutputTokens: 300,
75+
totalCacheCreationTokens: 90, totalCacheReadTokens: 120,
76+
totalCostUsd: 0.06, recordCount: 3, sessions: 3,
77+
})),
78+
getRateTiers: vi.fn(() => []),
79+
getSessionUsage: vi.fn(() => []),
80+
getCostByKey: vi.fn(() => []),
81+
getUsageByKey: vi.fn(() => []),
82+
};
83+
}
84+
85+
function mockMetricsCache() {
86+
return {
87+
getMetrics: vi.fn(() => ({
88+
tokenUsageByModel: [
89+
{
90+
model: 'sonnet', inputTokens: 600, outputTokens: 300,
91+
cacheCreationTokens: 90, cacheReadTokens: 120,
92+
estimatedCostUsd: 0.06,
93+
},
94+
],
95+
costTrends: [],
96+
errorRates: { totalSessions: 3 },
97+
topApiKeys: [],
98+
generatedAt: new Date(NOW).toISOString(),
99+
})),
100+
};
101+
}
102+
103+
function buildApp() {
104+
const app = Fastify({ logger: false });
105+
106+
app.addHook('onRequest', async (req: any) => {
107+
req.authKeyId = 'admin-key';
108+
req.tenantId = SYSTEM_TENANT;
109+
});
110+
111+
const ctx = {
112+
sessions: { listSessions: vi.fn(() => []), getSession: vi.fn() },
113+
auth: { authEnabled: true },
114+
metering: mockMetering() as any,
115+
metricsCache: mockMetricsCache() as any,
116+
quotas: {} as any,
117+
config: { enforceSessionOwnership: true },
118+
} as unknown as RouteContext;
119+
120+
registerAnalyticsRoutes(app, ctx);
121+
return app;
122+
}
123+
124+
describe('GET /v1/analytics/tokens β€” dailyBreakdown (Issue #3282)', () => {
125+
let app: FastifyInstance;
126+
127+
beforeEach(() => {
128+
app = buildApp();
129+
});
130+
131+
afterEach(async () => {
132+
await app.close();
133+
});
134+
135+
async function inject(uri: string) {
136+
return app.inject({
137+
method: 'GET',
138+
url: uri,
139+
headers: { authorization: 'Bearer admin-key' },
140+
});
141+
}
142+
143+
it('returns dailyBreakdown with aggregated token counts per day', async () => {
144+
const res = await inject('/v1/analytics/tokens');
145+
expect(res.statusCode).toBe(200);
146+
const body = res.json();
147+
148+
expect(body.dailyBreakdown).toHaveLength(2);
149+
150+
// May 10: 100+200=300 input, 50+100=150 output, 20+40=60 cacheRead, 10+30=40 cacheWrite
151+
const may10 = body.dailyBreakdown.find((d: any) => d.date === '2026-05-10');
152+
expect(may10).toBeDefined();
153+
expect(may10.inputTokens).toBe(300);
154+
expect(may10.outputTokens).toBe(150);
155+
expect(may10.cacheReadTokens).toBe(60);
156+
expect(may10.cacheWriteTokens).toBe(40);
157+
158+
// May 12: 300 input, 150 output, 60 cacheRead, 50 cacheWrite
159+
const may12 = body.dailyBreakdown.find((d: any) => d.date === '2026-05-12');
160+
expect(may12).toBeDefined();
161+
expect(may12.inputTokens).toBe(300);
162+
expect(may12.outputTokens).toBe(150);
163+
});
164+
165+
it('filters dailyBreakdown by from/to query params', async () => {
166+
const res = await inject('/v1/analytics/tokens?from=2026-05-12T00:00:00Z&to=2026-05-12T23:59:59Z');
167+
expect(res.statusCode).toBe(200);
168+
const body = res.json();
169+
170+
expect(body.dailyBreakdown).toHaveLength(1);
171+
expect(body.dailyBreakdown[0].date).toBe('2026-05-12');
172+
expect(body.dailyBreakdown[0].inputTokens).toBe(300);
173+
});
174+
175+
it('returns empty dailyBreakdown for out-of-range dates', async () => {
176+
const res = await inject('/v1/analytics/tokens?from=2020-01-01T00:00:00Z&to=2020-01-02T00:00:00Z');
177+
expect(res.statusCode).toBe(200);
178+
const body = res.json();
179+
expect(body.dailyBreakdown).toEqual([]);
180+
});
181+
182+
it('returns empty dailyBreakdown when no records exist', async () => {
183+
// Build app with empty records
184+
const app2 = Fastify({ logger: false });
185+
app2.addHook('onRequest', async (req: any) => {
186+
req.authKeyId = 'admin-key';
187+
req.tenantId = SYSTEM_TENANT;
188+
});
189+
190+
const emptyMetering = mockMetering();
191+
emptyMetering.getDailyTokenBreakdown = vi.fn(() => []);
192+
193+
const ctx = {
194+
sessions: { listSessions: vi.fn(() => []), getSession: vi.fn() },
195+
auth: { authEnabled: true },
196+
metering: emptyMetering as any,
197+
metricsCache: mockMetricsCache() as any,
198+
quotas: {} as any,
199+
config: { enforceSessionOwnership: true },
200+
} as unknown as RouteContext;
201+
202+
registerAnalyticsRoutes(app2, ctx);
203+
204+
const res = await app2.inject({
205+
method: 'GET',
206+
url: '/v1/analytics/tokens',
207+
headers: { authorization: 'Bearer admin-key' },
208+
});
209+
expect(res.statusCode).toBe(200);
210+
expect(res.json().dailyBreakdown).toEqual([]);
211+
212+
await app2.close();
213+
});
214+
215+
it('preserves backward-compatible fields (totalTokens, modelDistribution, dailyCost)', async () => {
216+
const res = await inject('/v1/analytics/tokens');
217+
expect(res.statusCode).toBe(200);
218+
const body = res.json();
219+
220+
// Legacy fields still present
221+
expect(body.totalTokens).toBeDefined();
222+
expect(body.modelDistribution).toBeDefined();
223+
expect(body.dailyCost).toBeDefined();
224+
expect(body.generatedAt).toBeDefined();
225+
226+
// New field
227+
expect(body.dailyBreakdown).toBeDefined();
228+
});
229+
});

β€Žsrc/__tests__/metering.test.tsβ€Ž

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -561,4 +561,79 @@ describe('MeteringService', () => {
561561
expect(svc.recordCount).toBe(10);
562562
});
563563
});
564+
// ── getDailyTokenBreakdown (Issue #3282) ─────────────────────────
565+
describe('getDailyTokenBreakdown', () => {
566+
it('returns empty array when no records exist', () => {
567+
expect(metering.getDailyTokenBreakdown()).toEqual([]);
568+
});
569+
570+
it('aggregates tokens by date', async () => {
571+
metering.recordTokenUsage('s1', { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 10, cacheReadTokens: 20 }, 'sonnet');
572+
await flushAsync();
573+
574+
metering.recordTokenUsage('s2', { inputTokens: 200, outputTokens: 100, cacheCreationTokens: 30, cacheReadTokens: 40 }, 'sonnet');
575+
await flushAsync();
576+
577+
const result = metering.getDailyTokenBreakdown();
578+
expect(result).toHaveLength(1);
579+
580+
const today = new Date().toISOString().slice(0, 10);
581+
expect(result[0].date).toBe(today);
582+
expect(result[0].inputTokens).toBe(300);
583+
expect(result[0].outputTokens).toBe(150);
584+
expect(result[0].cacheReadTokens).toBe(60);
585+
expect(result[0].cacheWriteTokens).toBe(40);
586+
});
587+
588+
it('filters by from/to date range', async () => {
589+
metering.recordTokenUsage('s1', { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 10, cacheReadTokens: 20 }, 'sonnet');
590+
await flushAsync();
591+
const records = (metering as any).records as UsageRecord[];
592+
if (records.length > 0) records[records.length - 1].timestamp = '2026-05-10T10:00:00Z';
593+
594+
metering.recordTokenUsage('s2', { inputTokens: 200, outputTokens: 100, cacheCreationTokens: 30, cacheReadTokens: 40 }, 'sonnet');
595+
await flushAsync();
596+
const records2 = (metering as any).records as UsageRecord[];
597+
if (records2.length > 1) records2[records2.length - 1].timestamp = '2026-05-12T10:00:00Z';
598+
599+
const result = metering.getDailyTokenBreakdown({ from: '2026-05-12T00:00:00Z', to: '2026-05-12T23:59:59Z' });
600+
expect(result).toHaveLength(1);
601+
expect(result[0].date).toBe('2026-05-12');
602+
expect(result[0].inputTokens).toBe(200);
603+
});
604+
605+
it('returns empty for out-of-range dates', async () => {
606+
metering.recordTokenUsage('s1', { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 10, cacheReadTokens: 20 }, 'sonnet');
607+
await flushAsync();
608+
609+
const result = metering.getDailyTokenBreakdown({ from: '2020-01-01T00:00:00Z', to: '2020-01-02T00:00:00Z' });
610+
expect(result).toEqual([]);
611+
});
612+
613+
it('separates records across different dates', async () => {
614+
metering.recordTokenUsage('s1', { inputTokens: 100, outputTokens: 50, cacheCreationTokens: 10, cacheReadTokens: 20 }, 'sonnet');
615+
await flushAsync();
616+
617+
metering.recordTokenUsage('s2', { inputTokens: 200, outputTokens: 100, cacheCreationTokens: 30, cacheReadTokens: 40 }, 'sonnet');
618+
await flushAsync();
619+
620+
metering.recordTokenUsage('s3', { inputTokens: 300, outputTokens: 150, cacheCreationTokens: 50, cacheReadTokens: 60 }, 'sonnet');
621+
await flushAsync();
622+
623+
// Backdate records to different days
624+
const recs = (metering as any).records as UsageRecord[];
625+
recs[0].timestamp = '2026-05-10T10:00:00Z';
626+
recs[1].timestamp = '2026-05-11T10:00:00Z';
627+
recs[2].timestamp = '2026-05-12T10:00:00Z';
628+
629+
const result = metering.getDailyTokenBreakdown();
630+
expect(result).toHaveLength(3);
631+
expect(result[0].date).toBe('2026-05-10');
632+
expect(result[0].inputTokens).toBe(100);
633+
expect(result[1].date).toBe('2026-05-11');
634+
expect(result[1].inputTokens).toBe(200);
635+
expect(result[2].date).toBe('2026-05-12');
636+
expect(result[2].inputTokens).toBe(300);
637+
});
638+
});
564639
});

β€Žsrc/metering.tsβ€Ž

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,27 @@ export class MeteringService {
374374
/**
375375
* Get per-session usage records.
376376
*/
377+
/** Get per-day token breakdown (Issue #3282). */
378+
getDailyTokenBreakdown(options?: { from?: string; to?: string }): Array<{ date: string; inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }> {
379+
const filtered = this.filterRecords({ from: options?.from, to: options?.to });
380+
const dayMap = new Map<string, { inputTokens: number; outputTokens: number; cacheReadTokens: number; cacheWriteTokens: number }>();
381+
for (const r of filtered) {
382+
const date = r.timestamp.slice(0, 10);
383+
let bucket = dayMap.get(date);
384+
if (!bucket) {
385+
bucket = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 };
386+
dayMap.set(date, bucket);
387+
}
388+
bucket.inputTokens += r.inputTokens;
389+
bucket.outputTokens += r.outputTokens;
390+
bucket.cacheReadTokens += r.cacheReadTokens;
391+
bucket.cacheWriteTokens += r.cacheCreationTokens;
392+
}
393+
return [...dayMap.entries()]
394+
.map(([date, d]) => ({ date, inputTokens: d.inputTokens, outputTokens: d.outputTokens, cacheReadTokens: d.cacheReadTokens, cacheWriteTokens: d.cacheWriteTokens }))
395+
.sort((a, b) => a.date.localeCompare(b.date));
396+
}
397+
377398
getSessionUsage(sessionId: string, options?: { from?: string; to?: string }): UsageRecord[] {
378399
return this.filterRecords({ sessionId, from: options?.from, to: options?.to });
379400
}

β€Žsrc/routes/analytics.tsβ€Ž

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import type { ApiKey } from '../services/auth/types.js';
2222
const GLOBAL_RATE_LIMIT = { max: 600, timeWindowMs: 60_000 };
2323

2424
export function registerAnalyticsRoutes(app: FastifyInstance, ctx: RouteContext): void {
25-
const { metricsCache, auth, quotas, sessions } = ctx;
25+
const { metricsCache, auth, quotas, sessions, metering } = ctx;
2626

2727
// ── Summary endpoint (delegates to MetricsCache) ────────────
2828
registerWithLegacy(app, 'get', '/v1/analytics/summary', {
@@ -73,12 +73,13 @@ export function registerAnalyticsRoutes(app: FastifyInstance, ctx: RouteContext)
7373
},
7474
});
7575

76-
// ── Token usage endpoint (Issue #2247) ──────────────────────
76+
// ── Token usage endpoint (Issue #2247, #3282) ──────────────
7777
registerWithLegacy(app, 'get', '/v1/analytics/tokens', {
7878
config: { rateLimit: { max: 60, timeWindow: '1 minute' } },
7979
handler: async (req: FastifyRequest, reply: FastifyReply) => {
8080
if (!requireRole(auth, req, reply, 'admin', 'operator', 'viewer')) return;
8181

82+
const query = req.query as { from?: string; to?: string };
8283
const metrics = metricsCache.getMetrics();
8384

8485
const totalTokens = metrics.tokenUsageByModel.reduce(
@@ -90,6 +91,12 @@ export function registerAnalyticsRoutes(app: FastifyInstance, ctx: RouteContext)
9091
0,
9192
);
9293

94+
// Per-day token breakdown from metering records (Issue #3282)
95+
const dailyBreakdown = metering.getDailyTokenBreakdown({
96+
from: query.from,
97+
to: query.to,
98+
});
99+
93100
return {
94101
totalTokens,
95102
totalCostUsd,
@@ -106,6 +113,7 @@ export function registerAnalyticsRoutes(app: FastifyInstance, ctx: RouteContext)
106113
estimatedCostUsd: d.cost,
107114
sessions: d.sessions,
108115
})),
116+
dailyBreakdown,
109117
generatedAt: metrics.generatedAt,
110118
};
111119
},

0 commit comments

Comments
Β (0)