Skip to content

Commit ed6bb60

Browse files
committed
feat(metrics): add creator read duration metrics and endpoint
Closes #138 record list/detail request counters and duration histogram buckets expose creator read metrics at GET /metrics/creators add focused tests for metrics registry and controller output document metrics endpoints in README
1 parent 6a97d56 commit ed6bb60

7 files changed

Lines changed: 316 additions & 1 deletion

File tree

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,8 @@ readinessProbe:
175175
## Indexer and ownership operations
176176

177177
- Ownership snapshot cleanup scaffold: [docs/indexer/ownership-snapshot-cleanup.md](./docs/indexer/ownership-snapshot-cleanup.md)
178+
179+
## Metrics
180+
181+
- Queue metrics: `GET /metrics/queues`
182+
- Creator read metrics: `GET /metrics/creators`

src/modules/creators/creators.routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { httpListCreators, httpGetCreatorStats } from './creators.controllers';
33
import { cacheControl } from '../../middlewares/cache-control.middleware';
44
import { CREATOR_PUBLIC_ROUTE_CACHE_PRESETS } from '../../constants/creator-public-cache.constants';
55
import { CREATOR_PUBLIC_ROUTE_NAMES } from '../../constants/creator-public-routes.constants';
6+
import { createCreatorReadMetricsMiddleware } from '../../utils/creator-read-metrics.utils';
67

78
const creatorsRouter = Router();
89

@@ -14,6 +15,7 @@ const creatorsRouter = Router();
1415
*/
1516
creatorsRouter.get(
1617
'/',
18+
createCreatorReadMetricsMiddleware('list'),
1719
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.LIST]),
1820
httpListCreators
1921
);
@@ -26,6 +28,7 @@ creatorsRouter.get(
2628
*/
2729
creatorsRouter.get(
2830
'/:id/stats',
31+
createCreatorReadMetricsMiddleware('detail'),
2932
cacheControl(CREATOR_PUBLIC_ROUTE_CACHE_PRESETS[CREATOR_PUBLIC_ROUTE_NAMES.GET_STATS]),
3033
httpGetCreatorStats
3134
);
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
jest.mock('../../utils/queue-metrics.utils', () => ({
2+
getQueueDepths: jest.fn(() => [{ queue: 'ingest', state: 'pending', depth: 3 }]),
3+
}));
4+
5+
jest.mock('../../utils/creator-read-metrics.utils', () => ({
6+
getCreatorReadMetrics: jest.fn(() => ({
7+
counters: {
8+
totalRequests: 2,
9+
totalSuccess: 1,
10+
totalClientErrors: 0,
11+
totalServerErrors: 1,
12+
byEndpoint: {
13+
list: {
14+
requests: 1,
15+
success: 1,
16+
clientErrors: 0,
17+
serverErrors: 0,
18+
histogram: {
19+
count: 1,
20+
sumMs: 12.5,
21+
buckets: [{ leMs: 5, count: 0 }],
22+
},
23+
},
24+
detail: {
25+
requests: 1,
26+
success: 0,
27+
clientErrors: 0,
28+
serverErrors: 1,
29+
histogram: {
30+
count: 1,
31+
sumMs: 84.2,
32+
buckets: [{ leMs: 5, count: 0 }],
33+
},
34+
},
35+
},
36+
},
37+
})),
38+
}));
39+
40+
import { creatorReadMetrics, queueMetrics } from './metrics.controllers';
41+
42+
describe('metrics.controllers', () => {
43+
it('returns queue depth metrics', () => {
44+
const res = {
45+
status: jest.fn().mockReturnThis(),
46+
json: jest.fn(),
47+
} as any;
48+
49+
queueMetrics({} as any, res);
50+
51+
expect(res.status).toHaveBeenCalledWith(200);
52+
expect(res.json).toHaveBeenCalledWith(
53+
expect.objectContaining({
54+
queues: [{ queue: 'ingest', state: 'pending', depth: 3 }],
55+
})
56+
);
57+
});
58+
59+
it('returns creator read metrics', () => {
60+
const res = {
61+
status: jest.fn().mockReturnThis(),
62+
json: jest.fn(),
63+
} as any;
64+
65+
creatorReadMetrics({} as any, res);
66+
67+
expect(res.status).toHaveBeenCalledWith(200);
68+
expect(res.json).toHaveBeenCalledWith(
69+
expect.objectContaining({
70+
creatorReads: expect.objectContaining({
71+
counters: expect.objectContaining({
72+
totalRequests: 2,
73+
byEndpoint: expect.objectContaining({
74+
list: expect.objectContaining({ requests: 1 }),
75+
}),
76+
}),
77+
}),
78+
})
79+
);
80+
});
81+
});

src/modules/metrics/metrics.controllers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Request, Response } from 'express';
22
import { getQueueDepths } from '../../utils/queue-metrics.utils';
3+
import { getCreatorReadMetrics } from '../../utils/creator-read-metrics.utils';
34

45
export const queueMetrics = (_: Request, res: Response): void => {
56
const queues = getQueueDepths();
@@ -8,3 +9,12 @@ export const queueMetrics = (_: Request, res: Response): void => {
89
queues,
910
});
1011
};
12+
13+
export const creatorReadMetrics = (_: Request, res: Response): void => {
14+
const creatorReads = getCreatorReadMetrics();
15+
16+
res.status(200).json({
17+
timestamp: new Date().toISOString(),
18+
creatorReads,
19+
});
20+
};
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import { Router } from 'express';
2-
import { queueMetrics } from './metrics.controllers';
2+
import { creatorReadMetrics, queueMetrics } from './metrics.controllers';
33

44
const router = Router();
55

66
// Queue depth metrics for all indexer worker queues
77
router.get('/queues', queueMetrics);
88

9+
// Creator read request duration metrics
10+
router.get('/creators', creatorReadMetrics);
11+
912
export default router;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {
2+
createCreatorReadMetricsMiddleware,
3+
getCreatorReadMetrics,
4+
recordCreatorReadMetric,
5+
resetCreatorReadMetrics,
6+
} from './creator-read-metrics.utils';
7+
8+
describe('creator-read-metrics.utils', () => {
9+
beforeEach(() => {
10+
resetCreatorReadMetrics();
11+
});
12+
13+
it('tracks counters and histogram buckets for creator reads', () => {
14+
recordCreatorReadMetric('list', 7, 200);
15+
recordCreatorReadMetric('detail', 120, 503);
16+
17+
const metrics = getCreatorReadMetrics();
18+
19+
expect(metrics.counters.totalRequests).toBe(2);
20+
expect(metrics.counters.totalSuccess).toBe(1);
21+
expect(metrics.counters.totalServerErrors).toBe(1);
22+
expect(metrics.counters.byEndpoint.list.requests).toBe(1);
23+
expect(metrics.counters.byEndpoint.detail.serverErrors).toBe(1);
24+
expect(metrics.counters.byEndpoint.list.histogram.buckets[0].count).toBe(0);
25+
expect(metrics.counters.byEndpoint.list.histogram.buckets[1].count).toBe(1);
26+
const lastBucketIndex =
27+
metrics.counters.byEndpoint.detail.histogram.buckets.length - 1;
28+
expect(metrics.counters.byEndpoint.detail.histogram.buckets[lastBucketIndex].count).toBe(1);
29+
});
30+
31+
it('records timing when the response finishes', () => {
32+
const clock = jest
33+
.fn()
34+
.mockReturnValueOnce(BigInt(1_000_000))
35+
.mockReturnValueOnce(BigInt(16_000_000));
36+
37+
const middleware = createCreatorReadMetricsMiddleware('list', clock);
38+
const finishHandlers: Array<() => void> = [];
39+
const res = {
40+
statusCode: 200,
41+
on: jest.fn((event: string, handler: () => void) => {
42+
if (event === 'finish') {
43+
finishHandlers.push(handler);
44+
}
45+
46+
return res;
47+
}),
48+
} as any;
49+
50+
middleware({} as any, res, jest.fn());
51+
finishHandlers[0]();
52+
53+
const metrics = getCreatorReadMetrics();
54+
55+
expect(metrics.counters.byEndpoint.list.requests).toBe(1);
56+
expect(metrics.counters.byEndpoint.list.success).toBe(1);
57+
expect(metrics.counters.byEndpoint.list.histogram.count).toBe(1);
58+
expect(metrics.counters.byEndpoint.list.histogram.sumMs).toBeGreaterThan(0);
59+
});
60+
});
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
import { RequestHandler } from 'express';
2+
3+
export type CreatorReadEndpoint = 'list' | 'detail';
4+
5+
const DURATION_BUCKETS_MS = [5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000] as const;
6+
7+
export interface CreatorReadHistogramBucket {
8+
leMs: number | null;
9+
count: number;
10+
}
11+
12+
export interface CreatorReadEndpointSnapshot {
13+
requests: number;
14+
success: number;
15+
clientErrors: number;
16+
serverErrors: number;
17+
histogram: {
18+
count: number;
19+
sumMs: number;
20+
buckets: CreatorReadHistogramBucket[];
21+
};
22+
}
23+
24+
export interface CreatorReadMetricsSnapshot {
25+
counters: {
26+
totalRequests: number;
27+
totalSuccess: number;
28+
totalClientErrors: number;
29+
totalServerErrors: number;
30+
byEndpoint: Record<CreatorReadEndpoint, CreatorReadEndpointSnapshot>;
31+
};
32+
}
33+
34+
type EndpointState = {
35+
requests: number;
36+
success: number;
37+
clientErrors: number;
38+
serverErrors: number;
39+
histogram: {
40+
count: number;
41+
sumMs: number;
42+
buckets: CreatorReadHistogramBucket[];
43+
};
44+
};
45+
46+
const createHistogramBuckets = (): CreatorReadHistogramBucket[] =>
47+
[...DURATION_BUCKETS_MS, null].map(leMs => ({
48+
leMs,
49+
count: 0,
50+
}));
51+
52+
const createEndpointState = (): EndpointState => ({
53+
requests: 0,
54+
success: 0,
55+
clientErrors: 0,
56+
serverErrors: 0,
57+
histogram: {
58+
count: 0,
59+
sumMs: 0,
60+
buckets: createHistogramBuckets(),
61+
},
62+
});
63+
64+
const registry: Record<CreatorReadEndpoint, EndpointState> = {
65+
list: createEndpointState(),
66+
detail: createEndpointState(),
67+
};
68+
69+
function classifyStatus(statusCode: number): 'success' | 'clientErrors' | 'serverErrors' {
70+
if (statusCode >= 500) {
71+
return 'serverErrors';
72+
}
73+
74+
if (statusCode >= 400) {
75+
return 'clientErrors';
76+
}
77+
78+
return 'success';
79+
}
80+
81+
function getHistogramBucketIndex(durationMs: number): number {
82+
const bucketIndex = DURATION_BUCKETS_MS.findIndex(limit => durationMs <= limit);
83+
return bucketIndex === -1 ? DURATION_BUCKETS_MS.length : bucketIndex;
84+
}
85+
86+
export function recordCreatorReadMetric(
87+
endpoint: CreatorReadEndpoint,
88+
durationMs: number,
89+
statusCode: number
90+
): void {
91+
const entry = registry[endpoint];
92+
const statusGroup = classifyStatus(statusCode);
93+
94+
entry.requests += 1;
95+
entry[statusGroup] += 1;
96+
entry.histogram.count += 1;
97+
entry.histogram.sumMs += durationMs;
98+
99+
const bucketIndex = getHistogramBucketIndex(durationMs);
100+
for (let index = bucketIndex; index < entry.histogram.buckets.length; index += 1) {
101+
entry.histogram.buckets[index].count += 1;
102+
}
103+
}
104+
105+
export function createCreatorReadMetricsMiddleware(
106+
endpoint: CreatorReadEndpoint,
107+
now: () => bigint = () => process.hrtime.bigint()
108+
): RequestHandler {
109+
return (_req, res, next) => {
110+
const startedAt = now();
111+
112+
res.on('finish', () => {
113+
const durationMs = Number(now() - startedAt) / 1e6;
114+
recordCreatorReadMetric(endpoint, durationMs, res.statusCode);
115+
});
116+
117+
next();
118+
};
119+
}
120+
121+
function snapshotEndpoint(entry: EndpointState): CreatorReadEndpointSnapshot {
122+
return {
123+
requests: entry.requests,
124+
success: entry.success,
125+
clientErrors: entry.clientErrors,
126+
serverErrors: entry.serverErrors,
127+
histogram: {
128+
count: entry.histogram.count,
129+
sumMs: entry.histogram.sumMs,
130+
buckets: entry.histogram.buckets.map(bucket => ({ ...bucket })),
131+
},
132+
};
133+
}
134+
135+
export function getCreatorReadMetrics(): CreatorReadMetricsSnapshot {
136+
return {
137+
counters: {
138+
totalRequests: registry.list.requests + registry.detail.requests,
139+
totalSuccess: registry.list.success + registry.detail.success,
140+
totalClientErrors: registry.list.clientErrors + registry.detail.clientErrors,
141+
totalServerErrors: registry.list.serverErrors + registry.detail.serverErrors,
142+
byEndpoint: {
143+
list: snapshotEndpoint(registry.list),
144+
detail: snapshotEndpoint(registry.detail),
145+
},
146+
},
147+
};
148+
}
149+
150+
export function resetCreatorReadMetrics(): void {
151+
registry.list = createEndpointState();
152+
registry.detail = createEndpointState();
153+
}

0 commit comments

Comments
 (0)