Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions backend/src/routes/v1/admin.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,21 +169,37 @@ 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<T extends object>(
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<Awaited<ReturnType<typeof buildAdminMetrics>>>(
ADMIN_METRICS_CACHE_KEY,
);
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' });
Expand Down
66 changes: 66 additions & 0 deletions backend/tests/integration/admin-metrics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading