autotel-hono 0.4.32 → 0.4.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -4
- package/src/index.test.ts +0 -622
- package/src/index.ts +0 -213
- package/src/metrics.ts +0 -92
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "autotel-hono",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.34",
|
|
4
4
|
"description": "OpenTelemetry Hono middleware powered by autotel",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -16,13 +16,12 @@
|
|
|
16
16
|
},
|
|
17
17
|
"files": [
|
|
18
18
|
"dist",
|
|
19
|
-
"src",
|
|
20
19
|
"README.md",
|
|
21
20
|
"skills"
|
|
22
21
|
],
|
|
23
22
|
"dependencies": {
|
|
24
|
-
"autotel": "4.
|
|
25
|
-
"autotel-adapters": "0.3.
|
|
23
|
+
"autotel": "4.2.0",
|
|
24
|
+
"autotel-adapters": "0.3.13"
|
|
26
25
|
},
|
|
27
26
|
"peerDependencies": {
|
|
28
27
|
"hono": ">=4.12.23"
|
package/src/index.test.ts
DELETED
|
@@ -1,622 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { Hono } from 'hono';
|
|
3
|
-
import { otel } from './index';
|
|
4
|
-
import type { Span, Tracer } from 'autotel';
|
|
5
|
-
import { SpanKind, propagation, context, otelTrace } from 'autotel';
|
|
6
|
-
import type { HttpMetricsConfig } from './metrics';
|
|
7
|
-
|
|
8
|
-
function createMockSpan() {
|
|
9
|
-
return {
|
|
10
|
-
setAttribute: vi.fn(),
|
|
11
|
-
setStatus: vi.fn(),
|
|
12
|
-
recordException: vi.fn(),
|
|
13
|
-
updateName: vi.fn(),
|
|
14
|
-
end: vi.fn(),
|
|
15
|
-
};
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function createMockTracer(
|
|
19
|
-
spanCollector: {
|
|
20
|
-
span: ReturnType<typeof createMockSpan>;
|
|
21
|
-
options: unknown;
|
|
22
|
-
parentContext?: unknown;
|
|
23
|
-
},
|
|
24
|
-
) {
|
|
25
|
-
const tracer: Tracer = {
|
|
26
|
-
startActiveSpan: vi.fn(
|
|
27
|
-
(
|
|
28
|
-
_name: string,
|
|
29
|
-
options: unknown,
|
|
30
|
-
parentContext: unknown,
|
|
31
|
-
callback: (span: Span) => Promise<unknown>,
|
|
32
|
-
) => {
|
|
33
|
-
const span = createMockSpan() as unknown as Span;
|
|
34
|
-
spanCollector.span = span as ReturnType<typeof createMockSpan>;
|
|
35
|
-
spanCollector.options = options;
|
|
36
|
-
spanCollector.parentContext = parentContext;
|
|
37
|
-
return callback(span);
|
|
38
|
-
},
|
|
39
|
-
),
|
|
40
|
-
} as unknown as Tracer;
|
|
41
|
-
return tracer;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function createMockMeter(recordCollector: {
|
|
45
|
-
durationRecords: Array<{ duration: number; attrs: Record<string, unknown> }>;
|
|
46
|
-
activeAdds: Array<{ delta: number; attrs: Record<string, unknown> }>;
|
|
47
|
-
}) {
|
|
48
|
-
const meter = {
|
|
49
|
-
createHistogram: vi.fn(() => ({
|
|
50
|
-
record: vi.fn((duration: number, attrs: Record<string, unknown>) => {
|
|
51
|
-
recordCollector.durationRecords.push({ duration, attrs });
|
|
52
|
-
}),
|
|
53
|
-
})),
|
|
54
|
-
createUpDownCounter: vi.fn(() => ({
|
|
55
|
-
add: vi.fn((delta: number, attrs: Record<string, unknown>) => {
|
|
56
|
-
recordCollector.activeAdds.push({ delta, attrs });
|
|
57
|
-
}),
|
|
58
|
-
})),
|
|
59
|
-
} as unknown as HttpMetricsConfig['meter'];
|
|
60
|
-
return meter;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
describe('otel middleware', () => {
|
|
64
|
-
it('creates a span and sets method, url, route, status', async () => {
|
|
65
|
-
const spanCollector: {
|
|
66
|
-
span: ReturnType<typeof createMockSpan>;
|
|
67
|
-
options: unknown;
|
|
68
|
-
} = { span: null!, options: null };
|
|
69
|
-
const recordCollector = {
|
|
70
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
71
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
72
|
-
};
|
|
73
|
-
const tracer = createMockTracer(spanCollector);
|
|
74
|
-
const meter = createMockMeter(recordCollector);
|
|
75
|
-
|
|
76
|
-
const app = new Hono().use(otel({ tracer, meter })).get('/hello', (c) => c.text('ok'));
|
|
77
|
-
|
|
78
|
-
const res = await app.request('http://localhost/hello', { method: 'GET' });
|
|
79
|
-
expect(res.status).toBe(200);
|
|
80
|
-
|
|
81
|
-
expect(spanCollector.options).toMatchObject({
|
|
82
|
-
kind: SpanKind.SERVER,
|
|
83
|
-
attributes: expect.objectContaining({
|
|
84
|
-
'http.request.method': 'GET',
|
|
85
|
-
'url.full': 'http://localhost/hello',
|
|
86
|
-
}),
|
|
87
|
-
});
|
|
88
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
89
|
-
'http.response.status_code',
|
|
90
|
-
200,
|
|
91
|
-
);
|
|
92
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith('http.route', '/hello');
|
|
93
|
-
expect(spanCollector.span.updateName).toHaveBeenCalledWith('GET /hello');
|
|
94
|
-
expect(spanCollector.span.end).toHaveBeenCalled();
|
|
95
|
-
|
|
96
|
-
expect(recordCollector.activeAdds.filter((a) => a.delta === 1)).toHaveLength(1);
|
|
97
|
-
expect(recordCollector.activeAdds.filter((a) => a.delta === -1)).toHaveLength(1);
|
|
98
|
-
expect(recordCollector.durationRecords).toHaveLength(1);
|
|
99
|
-
expect(recordCollector.durationRecords[0].duration).toBeGreaterThanOrEqual(0);
|
|
100
|
-
expect(recordCollector.durationRecords[0].attrs['http.response.status_code']).toBe(200);
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
it('sets serviceName and serviceVersion on span and metrics', async () => {
|
|
104
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
105
|
-
span: null!,
|
|
106
|
-
options: null,
|
|
107
|
-
};
|
|
108
|
-
const recordCollector = {
|
|
109
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
110
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
111
|
-
};
|
|
112
|
-
const tracer = createMockTracer(spanCollector);
|
|
113
|
-
const meter = createMockMeter(recordCollector);
|
|
114
|
-
|
|
115
|
-
const app = new Hono()
|
|
116
|
-
.use(otel({ tracer, meter, serviceName: 'my-api', serviceVersion: '1.2.3' }))
|
|
117
|
-
.get('/v1/foo', (c) => c.json({}));
|
|
118
|
-
|
|
119
|
-
await app.request('http://localhost/v1/foo', { method: 'GET' });
|
|
120
|
-
|
|
121
|
-
expect(spanCollector.options).toMatchObject({
|
|
122
|
-
attributes: expect.objectContaining({
|
|
123
|
-
'service.name': 'my-api',
|
|
124
|
-
'service.version': '1.2.3',
|
|
125
|
-
}),
|
|
126
|
-
});
|
|
127
|
-
expect(recordCollector.durationRecords[0].attrs['service.name']).toBe('my-api');
|
|
128
|
-
expect(recordCollector.durationRecords[0].attrs['service.version']).toBe('1.2.3');
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
it('captures request and response headers when configured', async () => {
|
|
132
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
133
|
-
span: null!,
|
|
134
|
-
options: null,
|
|
135
|
-
};
|
|
136
|
-
const recordCollector = {
|
|
137
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
138
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
139
|
-
};
|
|
140
|
-
const tracer = createMockTracer(spanCollector);
|
|
141
|
-
const meter = createMockMeter(recordCollector);
|
|
142
|
-
|
|
143
|
-
const app = new Hono()
|
|
144
|
-
.use(
|
|
145
|
-
otel({
|
|
146
|
-
tracer,
|
|
147
|
-
meter,
|
|
148
|
-
captureRequestHeaders: ['x-request-id', 'content-type'],
|
|
149
|
-
captureResponseHeaders: ['content-type'],
|
|
150
|
-
}),
|
|
151
|
-
)
|
|
152
|
-
.get('/r', (c) => {
|
|
153
|
-
c.header('Content-Type', 'application/json');
|
|
154
|
-
return c.json({});
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
await app.request('http://localhost/r', {
|
|
158
|
-
method: 'GET',
|
|
159
|
-
headers: { 'x-request-id': 'req-123', 'content-type': 'application/json' },
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
163
|
-
'http.request.header.x-request-id',
|
|
164
|
-
'req-123',
|
|
165
|
-
);
|
|
166
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
167
|
-
'http.request.header.content-type',
|
|
168
|
-
'application/json',
|
|
169
|
-
);
|
|
170
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
171
|
-
'http.response.header.content-type',
|
|
172
|
-
'application/json',
|
|
173
|
-
);
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
it('sets ERROR status and records exception when handler throws', async () => {
|
|
177
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
178
|
-
span: null!,
|
|
179
|
-
options: null,
|
|
180
|
-
};
|
|
181
|
-
const recordCollector = {
|
|
182
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
183
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
184
|
-
};
|
|
185
|
-
const tracer = createMockTracer(spanCollector);
|
|
186
|
-
const meter = createMockMeter(recordCollector);
|
|
187
|
-
|
|
188
|
-
const app = new Hono()
|
|
189
|
-
.use(otel({ tracer, meter }))
|
|
190
|
-
.get('/err', () => {
|
|
191
|
-
throw new Error('boom');
|
|
192
|
-
});
|
|
193
|
-
|
|
194
|
-
// Hono logs unhandled errors to console.error by default. The test
|
|
195
|
-
// intentionally throws to verify span behavior — silence the framework
|
|
196
|
-
// log so the test output stays clean.
|
|
197
|
-
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
198
|
-
const res = await app.request('http://localhost/err', { method: 'GET' });
|
|
199
|
-
expect(res.status).toBe(500);
|
|
200
|
-
errSpy.mockRestore();
|
|
201
|
-
|
|
202
|
-
expect(spanCollector.span.setStatus).toHaveBeenCalledWith({ code: 2 }); // SpanStatusCode.ERROR
|
|
203
|
-
expect(spanCollector.span.recordException).toHaveBeenCalledWith(
|
|
204
|
-
expect.objectContaining({ message: 'boom' }),
|
|
205
|
-
);
|
|
206
|
-
expect(spanCollector.span.end).toHaveBeenCalled();
|
|
207
|
-
});
|
|
208
|
-
|
|
209
|
-
it('does not throw when recordException receives non-Error (robustness)', async () => {
|
|
210
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
211
|
-
span: null!,
|
|
212
|
-
options: null,
|
|
213
|
-
};
|
|
214
|
-
const recordCollector = {
|
|
215
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
216
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
217
|
-
};
|
|
218
|
-
const baseSpan = createMockSpan();
|
|
219
|
-
baseSpan.recordException = vi.fn((_e: unknown) => {
|
|
220
|
-
throw new Error('recordException fails on non-Error');
|
|
221
|
-
});
|
|
222
|
-
const tracerWithFragileSpan: Tracer = {
|
|
223
|
-
startActiveSpan: vi.fn(
|
|
224
|
-
(
|
|
225
|
-
_name: string,
|
|
226
|
-
_options: unknown,
|
|
227
|
-
_context: unknown,
|
|
228
|
-
callback: (span: Span) => Promise<unknown>,
|
|
229
|
-
) => {
|
|
230
|
-
spanCollector.span = baseSpan as ReturnType<typeof createMockSpan>;
|
|
231
|
-
spanCollector.options = _options;
|
|
232
|
-
return callback(baseSpan as unknown as Span);
|
|
233
|
-
},
|
|
234
|
-
),
|
|
235
|
-
} as unknown as Tracer;
|
|
236
|
-
const meter = createMockMeter(recordCollector);
|
|
237
|
-
|
|
238
|
-
const app = new Hono()
|
|
239
|
-
.use(otel({ tracer: tracerWithFragileSpan, meter }))
|
|
240
|
-
.get('/bad', () => {
|
|
241
|
-
throw 'string throw' as unknown as Error;
|
|
242
|
-
});
|
|
243
|
-
|
|
244
|
-
// Hono surfaces the string throw via console.error; silence for the test.
|
|
245
|
-
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
246
|
-
await expect(app.request('http://localhost/bad', { method: 'GET' })).rejects.toBe('string throw');
|
|
247
|
-
expect(baseSpan.end).toHaveBeenCalled();
|
|
248
|
-
errSpy.mockRestore();
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
it('when disableTracing is true, does not create span but still records metrics', async () => {
|
|
252
|
-
const recordCollector = {
|
|
253
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
254
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
255
|
-
};
|
|
256
|
-
const meter = createMockMeter(recordCollector);
|
|
257
|
-
|
|
258
|
-
const app = new Hono()
|
|
259
|
-
.use(otel({ disableTracing: true, meter }))
|
|
260
|
-
.get('/no-span', (c) => c.text('ok'));
|
|
261
|
-
|
|
262
|
-
const res = await app.request('http://localhost/no-span', { method: 'GET' });
|
|
263
|
-
expect(res.status).toBe(200);
|
|
264
|
-
expect(recordCollector.activeAdds.filter((a) => a.delta === 1)).toHaveLength(1);
|
|
265
|
-
expect(recordCollector.activeAdds.filter((a) => a.delta === -1)).toHaveLength(1);
|
|
266
|
-
expect(recordCollector.durationRecords).toHaveLength(1);
|
|
267
|
-
});
|
|
268
|
-
|
|
269
|
-
it('when disableTracing is true, should not call propagation.extract', async () => {
|
|
270
|
-
const extractSpy = vi
|
|
271
|
-
.spyOn(propagation, 'extract')
|
|
272
|
-
.mockImplementation(() => {
|
|
273
|
-
throw new Error('extract should not run when tracing is disabled');
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
try {
|
|
277
|
-
const recordCollector = {
|
|
278
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
279
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
280
|
-
};
|
|
281
|
-
const meter = createMockMeter(recordCollector);
|
|
282
|
-
|
|
283
|
-
const app = new Hono()
|
|
284
|
-
.use(otel({ disableTracing: true, meter }))
|
|
285
|
-
.get('/disable-tracing', (c) => c.text('ok'));
|
|
286
|
-
|
|
287
|
-
const res = await app.request('http://localhost/disable-tracing', { method: 'GET' });
|
|
288
|
-
expect(res.status).toBe(200);
|
|
289
|
-
expect(extractSpy).not.toHaveBeenCalled();
|
|
290
|
-
} finally {
|
|
291
|
-
extractSpy.mockRestore();
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
it('uses spanNameFactory when provided', async () => {
|
|
296
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
297
|
-
span: null!,
|
|
298
|
-
options: null,
|
|
299
|
-
};
|
|
300
|
-
const recordCollector = {
|
|
301
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
302
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
303
|
-
};
|
|
304
|
-
const tracer = createMockTracer(spanCollector);
|
|
305
|
-
const meter = createMockMeter(recordCollector);
|
|
306
|
-
|
|
307
|
-
const app = new Hono()
|
|
308
|
-
.use(
|
|
309
|
-
otel({
|
|
310
|
-
tracer,
|
|
311
|
-
meter,
|
|
312
|
-
spanNameFactory: (c) => `HTTP ${c.req.method} ${c.req.path}`,
|
|
313
|
-
}),
|
|
314
|
-
)
|
|
315
|
-
.get('/custom-name', (c) => c.text('ok'));
|
|
316
|
-
|
|
317
|
-
await app.request('http://localhost/custom-name', { method: 'GET' });
|
|
318
|
-
|
|
319
|
-
expect(spanCollector.options).toMatchObject({
|
|
320
|
-
attributes: expect.any(Object),
|
|
321
|
-
});
|
|
322
|
-
expect(spanCollector.span.updateName).toHaveBeenCalledWith('HTTP GET /custom-name');
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
it('sets correct span name and route for subapp route', async () => {
|
|
326
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
327
|
-
span: null!,
|
|
328
|
-
options: null,
|
|
329
|
-
};
|
|
330
|
-
const recordCollector = {
|
|
331
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
332
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
333
|
-
};
|
|
334
|
-
const tracer = createMockTracer(spanCollector);
|
|
335
|
-
const meter = createMockMeter(recordCollector);
|
|
336
|
-
|
|
337
|
-
const subapp = new Hono().get('/hello', (c) => c.text('from subapp'));
|
|
338
|
-
const app = new Hono()
|
|
339
|
-
.use(otel({ tracer, meter }))
|
|
340
|
-
.route('/subapp', subapp);
|
|
341
|
-
|
|
342
|
-
await app.request('http://localhost/subapp/hello', { method: 'GET' });
|
|
343
|
-
|
|
344
|
-
expect(spanCollector.span.updateName).toHaveBeenCalledWith('GET /subapp/hello');
|
|
345
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
346
|
-
'http.route',
|
|
347
|
-
'/subapp/hello',
|
|
348
|
-
);
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
it('handles header names case-insensitively (request and response)', async () => {
|
|
352
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
353
|
-
span: null!,
|
|
354
|
-
options: null,
|
|
355
|
-
};
|
|
356
|
-
const recordCollector = {
|
|
357
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
358
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
359
|
-
};
|
|
360
|
-
const tracer = createMockTracer(spanCollector);
|
|
361
|
-
const meter = createMockMeter(recordCollector);
|
|
362
|
-
|
|
363
|
-
const app = new Hono()
|
|
364
|
-
.use(
|
|
365
|
-
otel({
|
|
366
|
-
tracer,
|
|
367
|
-
meter,
|
|
368
|
-
captureRequestHeaders: ['Accept-Language', 'x-custom-header'],
|
|
369
|
-
captureResponseHeaders: ['Cache-Control', 'x-response-header'],
|
|
370
|
-
}),
|
|
371
|
-
)
|
|
372
|
-
.get('/case', (c) => {
|
|
373
|
-
c.header('Cache-Control', 'no-cache');
|
|
374
|
-
c.header('X-Response-Header', 'response-value');
|
|
375
|
-
return c.text('ok');
|
|
376
|
-
});
|
|
377
|
-
|
|
378
|
-
await app.request('http://localhost/case', {
|
|
379
|
-
method: 'GET',
|
|
380
|
-
headers: {
|
|
381
|
-
'Accept-Language': 'en-US',
|
|
382
|
-
'X-Custom-Header': 'custom-value',
|
|
383
|
-
},
|
|
384
|
-
});
|
|
385
|
-
|
|
386
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
387
|
-
'http.request.header.accept-language',
|
|
388
|
-
'en-US',
|
|
389
|
-
);
|
|
390
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
391
|
-
'http.request.header.x-custom-header',
|
|
392
|
-
'custom-value',
|
|
393
|
-
);
|
|
394
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
395
|
-
'http.response.header.cache-control',
|
|
396
|
-
'no-cache',
|
|
397
|
-
);
|
|
398
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
399
|
-
'http.response.header.x-response-header',
|
|
400
|
-
'response-value',
|
|
401
|
-
);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
it('does not capture headers not in the allow list', async () => {
|
|
405
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
406
|
-
span: null!,
|
|
407
|
-
options: null,
|
|
408
|
-
};
|
|
409
|
-
const recordCollector = {
|
|
410
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
411
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
412
|
-
};
|
|
413
|
-
const tracer = createMockTracer(spanCollector);
|
|
414
|
-
const meter = createMockMeter(recordCollector);
|
|
415
|
-
|
|
416
|
-
const app = new Hono()
|
|
417
|
-
.use(
|
|
418
|
-
otel({
|
|
419
|
-
tracer,
|
|
420
|
-
meter,
|
|
421
|
-
captureRequestHeaders: ['Content-Type'],
|
|
422
|
-
captureResponseHeaders: ['Content-Type'],
|
|
423
|
-
}),
|
|
424
|
-
)
|
|
425
|
-
.get('/foo', (c) => {
|
|
426
|
-
c.header('X-Secret', 'must-not-appear');
|
|
427
|
-
return c.text('ok');
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
await app.request('http://localhost/foo', {
|
|
431
|
-
headers: { Authorization: 'Bearer secret', 'Content-Type': 'text/plain' },
|
|
432
|
-
});
|
|
433
|
-
|
|
434
|
-
const setAttributeCalls = (spanCollector.span.setAttribute as ReturnType<typeof vi.fn>).mock
|
|
435
|
-
.calls as Array<[string, unknown]>;
|
|
436
|
-
const attrKeys = setAttributeCalls.map(([k]) => k);
|
|
437
|
-
expect(attrKeys).not.toContain('http.request.header.authorization');
|
|
438
|
-
expect(attrKeys).not.toContain('http.response.header.x-secret');
|
|
439
|
-
});
|
|
440
|
-
|
|
441
|
-
it('uses getTime for span startTime and end when provided', async () => {
|
|
442
|
-
const spanCollector: {
|
|
443
|
-
span: ReturnType<typeof createMockSpan>;
|
|
444
|
-
options: unknown;
|
|
445
|
-
} = { span: null!, options: null };
|
|
446
|
-
const recordCollector = {
|
|
447
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
448
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
449
|
-
};
|
|
450
|
-
const tracer = createMockTracer(spanCollector);
|
|
451
|
-
const meter = createMockMeter(recordCollector);
|
|
452
|
-
const customTime = 12_345;
|
|
453
|
-
|
|
454
|
-
const app = new Hono()
|
|
455
|
-
.use(otel({ tracer, meter, getTime: () => customTime }))
|
|
456
|
-
.get('/time', (c) => c.text('ok'));
|
|
457
|
-
|
|
458
|
-
await app.request('http://localhost/time', { method: 'GET' });
|
|
459
|
-
|
|
460
|
-
expect(spanCollector.options).toMatchObject({ startTime: customTime });
|
|
461
|
-
expect(spanCollector.span.end).toHaveBeenCalledWith(customTime);
|
|
462
|
-
});
|
|
463
|
-
|
|
464
|
-
it('marks span error for 5xx response without thrown exception', async () => {
|
|
465
|
-
const spanCollector: { span: ReturnType<typeof createMockSpan>; options: unknown } = {
|
|
466
|
-
span: null!,
|
|
467
|
-
options: null,
|
|
468
|
-
};
|
|
469
|
-
const recordCollector = {
|
|
470
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
471
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
472
|
-
};
|
|
473
|
-
const tracer = createMockTracer(spanCollector);
|
|
474
|
-
const meter = createMockMeter(recordCollector);
|
|
475
|
-
|
|
476
|
-
const app = new Hono()
|
|
477
|
-
.use(otel({ tracer, meter }))
|
|
478
|
-
.get('/boom', () => new Response('fail', { status: 503 }));
|
|
479
|
-
|
|
480
|
-
await app.request('http://localhost/boom', { method: 'GET' });
|
|
481
|
-
|
|
482
|
-
expect(spanCollector.span.setAttribute).toHaveBeenCalledWith(
|
|
483
|
-
'http.response.status_code',
|
|
484
|
-
503,
|
|
485
|
-
);
|
|
486
|
-
expect(spanCollector.span.setStatus).toHaveBeenCalledWith({ code: 2 });
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
it('does not crash without meter or tracer (uses global providers)', async () => {
|
|
490
|
-
const app = new Hono().use(otel({})).get('/no-config', (c) => c.text('ok'));
|
|
491
|
-
const res = await app.request('http://localhost/no-config', { method: 'GET' });
|
|
492
|
-
expect(res.status).toBe(200);
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
it('records duration metrics for subapp routes', async () => {
|
|
496
|
-
const recordCollector = {
|
|
497
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
498
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
499
|
-
};
|
|
500
|
-
const meter = createMockMeter(recordCollector);
|
|
501
|
-
|
|
502
|
-
const subapp = new Hono().get('/nested', (c) => c.text('nested'));
|
|
503
|
-
const app = new Hono()
|
|
504
|
-
.use(otel({ meter }))
|
|
505
|
-
.route('/api', subapp);
|
|
506
|
-
|
|
507
|
-
await app.request('http://localhost/api/nested', { method: 'GET' });
|
|
508
|
-
|
|
509
|
-
const durationForRoute = recordCollector.durationRecords.find(
|
|
510
|
-
(r) => r.attrs['http.route'] === '/api/nested',
|
|
511
|
-
);
|
|
512
|
-
expect(durationForRoute).toBeDefined();
|
|
513
|
-
expect(durationForRoute!.attrs['http.request.method']).toBe('GET');
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
it('records metrics for different HTTP methods and status codes', async () => {
|
|
517
|
-
const recordCollector = {
|
|
518
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
519
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
520
|
-
};
|
|
521
|
-
const meter = createMockMeter(recordCollector);
|
|
522
|
-
|
|
523
|
-
const app = new Hono()
|
|
524
|
-
.use(otel({ meter }))
|
|
525
|
-
.get('/success', (c) => c.text('ok'))
|
|
526
|
-
.post('/created', (c) => c.text('created', 201))
|
|
527
|
-
.get('/not-found', (c) => c.text('not found', 404));
|
|
528
|
-
|
|
529
|
-
await app.request('http://localhost/success');
|
|
530
|
-
await app.request('http://localhost/success');
|
|
531
|
-
await app.request('http://localhost/created', { method: 'POST' });
|
|
532
|
-
await app.request('http://localhost/not-found');
|
|
533
|
-
|
|
534
|
-
const routes = recordCollector.durationRecords.map((r) => r.attrs['http.route']);
|
|
535
|
-
expect(routes).toContain('/success');
|
|
536
|
-
expect(routes).toContain('/created');
|
|
537
|
-
expect(routes).toContain('/not-found');
|
|
538
|
-
const methods = recordCollector.durationRecords.map((r) => r.attrs['http.request.method']);
|
|
539
|
-
expect(methods).toContain('GET');
|
|
540
|
-
expect(methods).toContain('POST');
|
|
541
|
-
});
|
|
542
|
-
|
|
543
|
-
it('active requests increment and decrement use identical attributes', async () => {
|
|
544
|
-
const recordCollector = {
|
|
545
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
546
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
547
|
-
};
|
|
548
|
-
const meter = createMockMeter(recordCollector);
|
|
549
|
-
|
|
550
|
-
const app = new Hono().use(otel({ meter })).get('/attrs', (c) => c.text('ok'));
|
|
551
|
-
await app.request('http://localhost/attrs', { method: 'GET' });
|
|
552
|
-
|
|
553
|
-
expect(recordCollector.activeAdds).toHaveLength(2);
|
|
554
|
-
expect(recordCollector.activeAdds[0].delta).toBe(1);
|
|
555
|
-
expect(recordCollector.activeAdds[1].delta).toBe(-1);
|
|
556
|
-
expect(recordCollector.activeAdds[0].attrs).toEqual(recordCollector.activeAdds[1].attrs);
|
|
557
|
-
expect(recordCollector.activeAdds[0].attrs['http.request.method']).toBe('GET');
|
|
558
|
-
});
|
|
559
|
-
|
|
560
|
-
it('does not track active requests when captureActiveRequests is false', async () => {
|
|
561
|
-
const recordCollector = {
|
|
562
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
563
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
564
|
-
};
|
|
565
|
-
const meter = createMockMeter(recordCollector);
|
|
566
|
-
|
|
567
|
-
const app = new Hono()
|
|
568
|
-
.use(otel({ meter, captureActiveRequests: false }))
|
|
569
|
-
.get('/no-active', (c) => c.text('ok'));
|
|
570
|
-
|
|
571
|
-
await app.request('http://localhost/no-active', { method: 'GET' });
|
|
572
|
-
|
|
573
|
-
expect(recordCollector.activeAdds).toHaveLength(0);
|
|
574
|
-
expect(recordCollector.durationRecords).toHaveLength(1);
|
|
575
|
-
});
|
|
576
|
-
|
|
577
|
-
it('records duration metric when handler throws', async () => {
|
|
578
|
-
const recordCollector = {
|
|
579
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
580
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
581
|
-
};
|
|
582
|
-
const meter = createMockMeter(recordCollector);
|
|
583
|
-
|
|
584
|
-
const app = new Hono()
|
|
585
|
-
.use(otel({ meter }))
|
|
586
|
-
.get('/err-metric', () => {
|
|
587
|
-
throw new Error('fail');
|
|
588
|
-
});
|
|
589
|
-
|
|
590
|
-
await app.request('http://localhost/err-metric', { method: 'GET' }).catch(() => {});
|
|
591
|
-
|
|
592
|
-
const errRecord = recordCollector.durationRecords.find(
|
|
593
|
-
(r) => r.attrs['http.route'] === '/err-metric',
|
|
594
|
-
);
|
|
595
|
-
expect(errRecord).toBeDefined();
|
|
596
|
-
expect(errRecord!.attrs['http.response.status_code']).toBe(500);
|
|
597
|
-
});
|
|
598
|
-
|
|
599
|
-
it('honors parent context when request runs inside active span', async () => {
|
|
600
|
-
const spanCollector: {
|
|
601
|
-
span: ReturnType<typeof createMockSpan>;
|
|
602
|
-
options: unknown;
|
|
603
|
-
parentContext?: unknown;
|
|
604
|
-
} = { span: null!, options: null };
|
|
605
|
-
const recordCollector = {
|
|
606
|
-
durationRecords: [] as Array<{ duration: number; attrs: Record<string, unknown> }>,
|
|
607
|
-
activeAdds: [] as Array<{ delta: number; attrs: Record<string, unknown> }>,
|
|
608
|
-
};
|
|
609
|
-
const tracer = createMockTracer(spanCollector);
|
|
610
|
-
const meter = createMockMeter(recordCollector);
|
|
611
|
-
|
|
612
|
-
const app = new Hono().use(otel({ tracer, meter })).get('/child', (c) => c.text('ok'));
|
|
613
|
-
|
|
614
|
-
const parentSpan = createMockSpan() as unknown as Span;
|
|
615
|
-
const ctxWithParent = otelTrace.setSpan(context.active(), parentSpan);
|
|
616
|
-
await context.with(ctxWithParent, async () => {
|
|
617
|
-
await app.request('http://localhost/child', { method: 'GET' });
|
|
618
|
-
});
|
|
619
|
-
|
|
620
|
-
expect(spanCollector.parentContext).toBeDefined();
|
|
621
|
-
});
|
|
622
|
-
});
|
package/src/index.ts
DELETED
|
@@ -1,213 +0,0 @@
|
|
|
1
|
-
import type { Span, Tracer } from 'autotel';
|
|
2
|
-
import {
|
|
3
|
-
getTracer,
|
|
4
|
-
getMeter,
|
|
5
|
-
context as otelContext,
|
|
6
|
-
propagation,
|
|
7
|
-
SpanKind,
|
|
8
|
-
SpanStatusCode,
|
|
9
|
-
HTTPAttributes,
|
|
10
|
-
URLAttributes,
|
|
11
|
-
ServiceAttributes,
|
|
12
|
-
httpRequestHeaderAttribute,
|
|
13
|
-
httpResponseHeaderAttribute,
|
|
14
|
-
} from 'autotel';
|
|
15
|
-
import type { MiddlewareHandler, Context } from 'hono';
|
|
16
|
-
import { createMiddleware } from 'hono/factory';
|
|
17
|
-
import { routePath } from 'hono/route';
|
|
18
|
-
import {
|
|
19
|
-
createRequestDurationTracker,
|
|
20
|
-
createActiveRequestsTracker,
|
|
21
|
-
type HttpMetricsConfig,
|
|
22
|
-
} from './metrics';
|
|
23
|
-
|
|
24
|
-
const INSTRUMENTATION_SCOPE_NAME = 'autotel-hono';
|
|
25
|
-
|
|
26
|
-
type TimeInput = number | [number, number];
|
|
27
|
-
type TracerProvider = { getTracer(name: string, version?: string): Tracer };
|
|
28
|
-
type Meter = HttpMetricsConfig['meter'];
|
|
29
|
-
type MeterProvider = { getMeter(name: string, version?: string): Meter };
|
|
30
|
-
|
|
31
|
-
function now(): number {
|
|
32
|
-
const p = (globalThis as unknown as { performance?: { now(): number } })
|
|
33
|
-
.performance;
|
|
34
|
-
return p?.now() ?? Date.now();
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type OtelConfig = {
|
|
38
|
-
tracer?: Tracer;
|
|
39
|
-
tracerProvider?: TracerProvider;
|
|
40
|
-
meter?: Meter;
|
|
41
|
-
meterProvider?: MeterProvider;
|
|
42
|
-
tracerName?: string;
|
|
43
|
-
spanNameFactory?: (c: Context) => string;
|
|
44
|
-
captureRequestHeaders?: string[];
|
|
45
|
-
captureResponseHeaders?: string[];
|
|
46
|
-
captureActiveRequests?: boolean;
|
|
47
|
-
captureRequestDuration?: boolean;
|
|
48
|
-
serviceName?: string;
|
|
49
|
-
serviceVersion?: string;
|
|
50
|
-
disableTracing?: boolean;
|
|
51
|
-
getTime?(): TimeInput;
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
type NormalizedOtelConfig = OtelConfig & {
|
|
55
|
-
requestHeaderSet: Set<string>;
|
|
56
|
-
responseHeaderSet: Set<string>;
|
|
57
|
-
captureActiveRequests: boolean;
|
|
58
|
-
captureRequestDuration: boolean;
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
function normalizeConfig(config: OtelConfig = {}): NormalizedOtelConfig {
|
|
62
|
-
const reqHeadersSrc = [...(config.captureRequestHeaders ?? [])];
|
|
63
|
-
const resHeadersSrc = [...(config.captureResponseHeaders ?? [])];
|
|
64
|
-
const requestHeaderSet = new Set(reqHeadersSrc.map((h) => h.toLowerCase()));
|
|
65
|
-
const responseHeaderSet = new Set(resHeadersSrc.map((h) => h.toLowerCase()));
|
|
66
|
-
return {
|
|
67
|
-
...config,
|
|
68
|
-
requestHeaderSet,
|
|
69
|
-
responseHeaderSet,
|
|
70
|
-
captureActiveRequests: config.captureActiveRequests ?? true,
|
|
71
|
-
captureRequestDuration: config.captureRequestDuration ?? true,
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
function resolveTracer(config: NormalizedOtelConfig): Tracer | undefined {
|
|
76
|
-
if (config.disableTracing) return undefined;
|
|
77
|
-
if (config.tracer) return config.tracer;
|
|
78
|
-
if (config.tracerProvider) {
|
|
79
|
-
return config.tracerProvider.getTracer(
|
|
80
|
-
config.tracerName ?? INSTRUMENTATION_SCOPE_NAME,
|
|
81
|
-
config.serviceVersion,
|
|
82
|
-
);
|
|
83
|
-
}
|
|
84
|
-
return getTracer(config.tracerName ?? INSTRUMENTATION_SCOPE_NAME, config.serviceVersion);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
function resolveMeter(config: NormalizedOtelConfig): Meter {
|
|
88
|
-
if (config.meter) return config.meter;
|
|
89
|
-
if (config.meterProvider) {
|
|
90
|
-
return config.meterProvider.getMeter(
|
|
91
|
-
INSTRUMENTATION_SCOPE_NAME,
|
|
92
|
-
config.serviceVersion,
|
|
93
|
-
);
|
|
94
|
-
}
|
|
95
|
-
return getMeter();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function otel(userConfig: OtelConfig = {}): MiddlewareHandler {
|
|
99
|
-
const config = normalizeConfig(userConfig);
|
|
100
|
-
const tracer = resolveTracer(config);
|
|
101
|
-
const meter = resolveMeter(config);
|
|
102
|
-
|
|
103
|
-
const metricsConfig: HttpMetricsConfig = {
|
|
104
|
-
meter,
|
|
105
|
-
captureRequestDuration: config.captureRequestDuration,
|
|
106
|
-
captureActiveRequests: config.captureActiveRequests,
|
|
107
|
-
};
|
|
108
|
-
const requestDuration = createRequestDurationTracker(metricsConfig);
|
|
109
|
-
const activeReqs = createActiveRequestsTracker(metricsConfig);
|
|
110
|
-
|
|
111
|
-
const spanName = (c: Context) =>
|
|
112
|
-
config.spanNameFactory?.(c) ?? `${c.req.method} ${routePath(c)}`;
|
|
113
|
-
|
|
114
|
-
return createMiddleware(async (c, next) => {
|
|
115
|
-
const method = c.req.method;
|
|
116
|
-
|
|
117
|
-
const stableAttrs: Record<string, string | number | undefined> = {
|
|
118
|
-
[HTTPAttributes.requestMethod]: method,
|
|
119
|
-
[ServiceAttributes.name]: config.serviceName,
|
|
120
|
-
[ServiceAttributes.version]: config.serviceVersion,
|
|
121
|
-
};
|
|
122
|
-
|
|
123
|
-
activeReqs?.increment(stableAttrs);
|
|
124
|
-
const startTime = now();
|
|
125
|
-
|
|
126
|
-
const deferredRequestHeaderAttributes: Record<string, string> = {};
|
|
127
|
-
const reqHeaders = c.req.raw.headers;
|
|
128
|
-
for (const [rawName, value] of reqHeaders.entries()) {
|
|
129
|
-
const name = rawName.toLowerCase();
|
|
130
|
-
if (config.requestHeaderSet.has(name)) {
|
|
131
|
-
deferredRequestHeaderAttributes[httpRequestHeaderAttribute(name)] =
|
|
132
|
-
typeof value === 'string' ? value : value[0] ?? '';
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
const finalize = (span?: Span, error?: unknown) => {
|
|
137
|
-
try {
|
|
138
|
-
const status = c.res.status;
|
|
139
|
-
|
|
140
|
-
if (span) {
|
|
141
|
-
for (const [name, value] of c.res.headers.entries()) {
|
|
142
|
-
const lower = name.toLowerCase();
|
|
143
|
-
if (config.responseHeaderSet.has(lower)) {
|
|
144
|
-
span.setAttribute(httpResponseHeaderAttribute(lower), value);
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
span.setAttribute(HTTPAttributes.responseStatusCode, status);
|
|
148
|
-
if (status >= 500) {
|
|
149
|
-
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
150
|
-
}
|
|
151
|
-
if (error) {
|
|
152
|
-
try {
|
|
153
|
-
span.recordException(error as Error);
|
|
154
|
-
} catch {
|
|
155
|
-
// Ignore errors when recording exception
|
|
156
|
-
}
|
|
157
|
-
span.setStatus({ code: SpanStatusCode.ERROR });
|
|
158
|
-
}
|
|
159
|
-
}
|
|
160
|
-
} finally {
|
|
161
|
-
activeReqs?.decrement(stableAttrs);
|
|
162
|
-
span?.setAttribute(HTTPAttributes.route, routePath(c));
|
|
163
|
-
span?.updateName(spanName(c));
|
|
164
|
-
const durationSeconds = (now() - startTime) / 1000;
|
|
165
|
-
requestDuration.record(durationSeconds, {
|
|
166
|
-
...stableAttrs,
|
|
167
|
-
[HTTPAttributes.route]: routePath(c),
|
|
168
|
-
[HTTPAttributes.responseStatusCode]: c.res.status,
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
};
|
|
172
|
-
|
|
173
|
-
if (!tracer) {
|
|
174
|
-
try {
|
|
175
|
-
await next();
|
|
176
|
-
finalize();
|
|
177
|
-
} catch (error) {
|
|
178
|
-
finalize(undefined, error);
|
|
179
|
-
throw error;
|
|
180
|
-
}
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const parent = propagation.extract(otelContext.active(), c.req.header());
|
|
185
|
-
return tracer.startActiveSpan(
|
|
186
|
-
spanName(c),
|
|
187
|
-
{
|
|
188
|
-
kind: SpanKind.SERVER,
|
|
189
|
-
startTime: config.getTime?.(),
|
|
190
|
-
attributes: {
|
|
191
|
-
...stableAttrs,
|
|
192
|
-
[URLAttributes.full]: c.req.url,
|
|
193
|
-
[HTTPAttributes.route]: routePath(c),
|
|
194
|
-
},
|
|
195
|
-
},
|
|
196
|
-
parent,
|
|
197
|
-
async (span) => {
|
|
198
|
-
try {
|
|
199
|
-
for (const [k, v] of Object.entries(deferredRequestHeaderAttributes)) {
|
|
200
|
-
span.setAttribute(k, v);
|
|
201
|
-
}
|
|
202
|
-
await next();
|
|
203
|
-
finalize(span, c.error);
|
|
204
|
-
} catch (error) {
|
|
205
|
-
finalize(span, error);
|
|
206
|
-
throw error;
|
|
207
|
-
} finally {
|
|
208
|
-
span.end(config.getTime?.());
|
|
209
|
-
}
|
|
210
|
-
},
|
|
211
|
-
);
|
|
212
|
-
});
|
|
213
|
-
}
|
package/src/metrics.ts
DELETED
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* HTTP server metrics per OTel semantic conventions.
|
|
3
|
-
*
|
|
4
|
-
* Lazy-initialized request duration histogram and active-requests up/down counter.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
|
-
type AttributeValue = string | number | boolean | undefined;
|
|
8
|
-
export type Attributes = Record<string, AttributeValue>;
|
|
9
|
-
|
|
10
|
-
type Histogram = {
|
|
11
|
-
record: (value: number, attrs?: Attributes) => void;
|
|
12
|
-
};
|
|
13
|
-
|
|
14
|
-
type UpDownCounter = {
|
|
15
|
-
add: (value: number, attrs?: Attributes) => void;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
export type Meter = {
|
|
19
|
-
createHistogram: (
|
|
20
|
-
name: string,
|
|
21
|
-
options?: {
|
|
22
|
-
description?: string;
|
|
23
|
-
unit?: string;
|
|
24
|
-
advice?: { explicitBucketBoundaries?: number[] };
|
|
25
|
-
},
|
|
26
|
-
) => Histogram;
|
|
27
|
-
createUpDownCounter: (
|
|
28
|
-
name: string,
|
|
29
|
-
options?: {
|
|
30
|
-
description?: string;
|
|
31
|
-
},
|
|
32
|
-
) => UpDownCounter;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/** OTel HTTP server metric names (stable convention names) */
|
|
36
|
-
const METRIC_HTTP_SERVER_REQUEST_DURATION = 'http.server.request.duration';
|
|
37
|
-
const METRIC_HTTP_SERVER_ACTIVE_REQUESTS = 'http.server.active_requests';
|
|
38
|
-
|
|
39
|
-
/** Recommended bucket boundaries for request duration (seconds) */
|
|
40
|
-
const HTTP_DURATION_BUCKETS = [
|
|
41
|
-
0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
|
|
42
|
-
];
|
|
43
|
-
|
|
44
|
-
export type HttpMetricsConfig = {
|
|
45
|
-
meter: Meter;
|
|
46
|
-
captureRequestDuration?: boolean;
|
|
47
|
-
captureActiveRequests?: boolean;
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
export function createRequestDurationTracker(config: HttpMetricsConfig): {
|
|
51
|
-
record: (durationSeconds: number, attrs: Attributes) => void;
|
|
52
|
-
} {
|
|
53
|
-
if (config.captureRequestDuration === false) {
|
|
54
|
-
return { record: () => {} };
|
|
55
|
-
}
|
|
56
|
-
const histogram = config.meter.createHistogram(
|
|
57
|
-
METRIC_HTTP_SERVER_REQUEST_DURATION,
|
|
58
|
-
{
|
|
59
|
-
description: 'Duration of HTTP server requests in seconds',
|
|
60
|
-
unit: 's',
|
|
61
|
-
advice: { explicitBucketBoundaries: HTTP_DURATION_BUCKETS },
|
|
62
|
-
},
|
|
63
|
-
);
|
|
64
|
-
return {
|
|
65
|
-
record(durationSeconds: number, attrs: Attributes) {
|
|
66
|
-
histogram.record(durationSeconds, attrs);
|
|
67
|
-
},
|
|
68
|
-
};
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function createActiveRequestsTracker(config: HttpMetricsConfig): {
|
|
72
|
-
increment: (attrs: Attributes) => void;
|
|
73
|
-
decrement: (attrs: Attributes) => void;
|
|
74
|
-
} | undefined {
|
|
75
|
-
if (config.captureActiveRequests === false) {
|
|
76
|
-
return undefined;
|
|
77
|
-
}
|
|
78
|
-
const counter = config.meter.createUpDownCounter(
|
|
79
|
-
METRIC_HTTP_SERVER_ACTIVE_REQUESTS,
|
|
80
|
-
{
|
|
81
|
-
description: 'Number of active (in-flight) HTTP server requests',
|
|
82
|
-
},
|
|
83
|
-
);
|
|
84
|
-
return {
|
|
85
|
-
increment(attrs: Attributes) {
|
|
86
|
-
counter.add(1, attrs);
|
|
87
|
-
},
|
|
88
|
-
decrement(attrs: Attributes) {
|
|
89
|
-
counter.add(-1, attrs);
|
|
90
|
-
},
|
|
91
|
-
};
|
|
92
|
-
}
|