diff --git a/backend/src/routes/v1/admin.routes.ts b/backend/src/routes/v1/admin.routes.ts index 798b00cb..93b1a817 100644 --- a/backend/src/routes/v1/admin.routes.ts +++ b/backend/src/routes/v1/admin.routes.ts @@ -169,6 +169,22 @@ function withLiveIndexerCounters< }; } +/** + * Attach a `calculatedAt` timestamp reflecting when the underlying aggregation + * actually ran (i.e. when the metrics payload was stored in the cache), not + * when this HTTP response is serialized. On a cache HIT this stays stable + * across requests served from the same cache entry (Issue #1240). + */ +function withCalculatedAt( + payload: T, +): T & { calculatedAt: string } { + const createdAt = cache.getMetadata(ADMIN_METRICS_CACHE_KEY)?.createdAt; + return { + ...payload, + calculatedAt: createdAt ?? new Date().toISOString(), + }; +} + router.get('/metrics', async (_req: Request, res: Response) => { try { const cached = cache.get>>( @@ -176,14 +192,14 @@ router.get('/metrics', async (_req: Request, res: Response) => { ); if (cached) { res.set('X-Cache', 'HIT'); - res.json(withLiveIndexerCounters(cached)); + res.json(withLiveIndexerCounters(withCalculatedAt(cached))); return; } const payload = await buildAdminMetrics(); cache.set(ADMIN_METRICS_CACHE_KEY, payload, ADMIN_METRICS_CACHE_TTL_SECONDS); res.set('X-Cache', 'MISS'); - res.json(payload); + res.json(withCalculatedAt(payload)); } catch (err) { logger.error('Error fetching admin metrics:', err); res.status(500).json({ error: 'Internal server error' }); diff --git a/backend/tests/integration/admin-metrics.test.ts b/backend/tests/integration/admin-metrics.test.ts index 76ea5530..5db22988 100644 --- a/backend/tests/integration/admin-metrics.test.ts +++ b/backend/tests/integration/admin-metrics.test.ts @@ -333,6 +333,72 @@ describe('GET /v1/admin/metrics', () => { degraded: true, }); }); + + it('includes a calculatedAt timestamp in valid ISO 8601 format (#1240)', async () => { + setupCounts({ total: 5, active: 3 }); + mocks.cache.getMetadata.mockReturnValue({ + createdAt: '2026-08-01T00:00:00.000Z', + expiresAt: '2026-08-01T00:01:00.000Z', + }); + + const res = await request(app) + .get('/v1/admin/metrics') + .set('Authorization', `Bearer ${createToken()}`); + + expect(res.status).toBe(200); + expect(typeof res.body.calculatedAt).toBe('string'); + expect(res.body.calculatedAt).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ); + expect(Date.parse(res.body.calculatedAt)).not.toBeNaN(); + // Reflects when the aggregation ran, not when the response was serialized. + expect(res.body.calculatedAt).toBe('2026-08-01T00:00:00.000Z'); + expect(res.body.calculatedAt).not.toBe(new Date().toISOString()); + }); + + it('keeps calculatedAt stable across responses served from the same cache entry (#1240)', async () => { + const cachedPayload = { + total_streams: 99, + active_streams: 50, + paused_streams: 5, + completed_streams: 30, + cancelled_streams: 14, + total_volume_streamed: '123456789', + indexer: { + lastLedger: 10, + lagSeconds: 1, + lastUpdated: null, + eventsProcessed: 0, + eventsFailed: 0, + lastErrorAt: null, + degraded: false, + }, + }; + const aggregationTime = '2026-08-01T00:00:00.000Z'; + mocks.cache.get.mockReturnValue(cachedPayload); + mocks.cache.getMetadata.mockReturnValue({ + createdAt: aggregationTime, + expiresAt: '2026-08-01T00:01:00.000Z', + }); + + const first = await request(app) + .get('/v1/admin/metrics') + .set('Authorization', `Bearer ${createToken()}`); + const second = await request(app) + .get('/v1/admin/metrics') + .set('Authorization', `Bearer ${createToken()}`); + + expect(first.status).toBe(200); + expect(second.status).toBe(200); + expect(first.headers['x-cache']).toBe('HIT'); + expect(second.headers['x-cache']).toBe('HIT'); + // Both requests served from the same cache entry: calculatedAt must equal + // the aggregation time and must NOT drift to "now" on each call. + expect(first.body.calculatedAt).toBe(aggregationTime); + expect(second.body.calculatedAt).toBe(aggregationTime); + expect(second.body.calculatedAt).toEqual(first.body.calculatedAt); + expect(second.body.calculatedAt).not.toBe(new Date().toISOString()); + }); }); describe('GET /v1/admin/indexer/status', () => {