@vercube/telemetry 1.3.0

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/dist/index.mjs ADDED
@@ -0,0 +1,1085 @@
1
+ import { a as INSTRUMENTATION_SCOPE, c as URL_PATH, d as USER_AGENT_ORIGINAL, f as VERCUBE_CONTROLLER, h as VERCUBE_HANDLER, i as HTTP_ROUTE, l as URL_QUERY, m as VERCUBE_DI_KIND, n as HTTP_REQUEST_METHOD, o as SERVER_ADDRESS, p as VERCUBE_DI_KEY, r as HTTP_RESPONSE_STATUS_CODE, s as SERVER_PORT, t as ERROR_TYPE, u as URL_SCHEME } from "./Attributes-QVHj8ZgV.mjs";
2
+ import { i as headersSetter, n as W3CTraceContextPropagator, r as headersGetter, t as VercubeContextManager } from "./VercubeContextManager-n4jHlB-w.mjs";
3
+ import { n as errorType, o as runInSpan, r as failSpan } from "./SpanUtils-t3NCeswB.mjs";
4
+ import { ROOT_CONTEXT, SpanKind, ValueType, context, metrics, propagation, trace } from "@opentelemetry/api";
5
+ import { BasePlugin, RequestContext, TelemetryRegistry, getRequestPathname, getRequestSearch, isSecretKey, resolveTelemetryOptions } from "@vercube/core";
6
+ import { Logger, enricherPlugin } from "@vercube/logger";
7
+ import { IOC, addIOCDevtoolsHook } from "@vercube/di";
8
+ import { monitorEventLoopDelay, performance as performance$1 } from "node:perf_hooks";
9
+ import { getHeapStatistics } from "node:v8";
10
+ //#region src/Common/Telemetry.ts
11
+ /**
12
+ * Dependency-injection token and public API for telemetry.
13
+ *
14
+ * Injected the same way as `Logger`:
15
+ *
16
+ * ```ts
17
+ * class InvoiceService {
18
+ * @Inject(Telemetry)
19
+ * private gTelemetry!: Telemetry;
20
+ *
21
+ * public async refund(id: string) {
22
+ * return this.gTelemetry.span('invoice.refund', (span) => {
23
+ * span.setAttribute('invoice.id', id);
24
+ * return this.doRefund(id);
25
+ * });
26
+ * }
27
+ * }
28
+ * ```
29
+ *
30
+ * The token is only bound when telemetry is enabled, so inject it with
31
+ * `@InjectOptional` from code that must also run without it.
32
+ */
33
+ var Telemetry = class {};
34
+ //#endregion
35
+ //#region src/Bootstrap/BootstrapSpans.ts
36
+ /** Upper bound on buffered construction records, so a pathological app cannot exhaust memory. */
37
+ const MAX_RECORDS = 2e4;
38
+ /** Name of the span every construction span hangs under. */
39
+ const BOOTSTRAP_SPAN_NAME = "vercube.bootstrap";
40
+ /**
41
+ * Buffers container constructions and replays them as a trace.
42
+ *
43
+ * Bootstrap has to be observed before the container exists, which is earlier
44
+ * than any tracer is available, so the records are buffered and turned into
45
+ * spans afterwards with their original timestamps. The result is that the
46
+ * application's startup shows up in a trace viewer as an ordinary waterfall
47
+ * rather than as a bespoke profiler view.
48
+ */
49
+ var BootstrapRecorder = class {
50
+ /** Buffered construction records, in emission order. */
51
+ fRecords = [];
52
+ /** Removes the container observer. */
53
+ fDetach = null;
54
+ /** Whether the records have already been turned into spans. */
55
+ fEmitted = false;
56
+ /**
57
+ * Starts observing container construction.
58
+ *
59
+ * @returns This recorder
60
+ */
61
+ install() {
62
+ if (this.fDetach) return this;
63
+ this.fDetach = addIOCDevtoolsHook({ onResolved: (record) => {
64
+ if (this.fEmitted || this.fRecords.length >= MAX_RECORDS) return;
65
+ this.fRecords.push(record);
66
+ } });
67
+ return this;
68
+ }
69
+ /** Whether anything is still waiting to be emitted. */
70
+ get pending() {
71
+ return !this.fEmitted && this.fRecords.length > 0;
72
+ }
73
+ /**
74
+ * Replays the buffered constructions as spans and stops observing.
75
+ *
76
+ * @param tracer - Tracer the spans are created on
77
+ */
78
+ emit(tracer) {
79
+ if (this.fEmitted) return;
80
+ this.fEmitted = true;
81
+ this.fDetach?.();
82
+ this.fDetach = null;
83
+ const records = this.fRecords;
84
+ this.fRecords = [];
85
+ if (records.length === 0) return;
86
+ const origin = Math.min(...records.map((record) => record.start));
87
+ const end = Math.max(...records.map((record) => record.end));
88
+ const root = tracer.startSpan(BOOTSTRAP_SPAN_NAME, {
89
+ kind: SpanKind.INTERNAL,
90
+ root: true,
91
+ startTime: toEpoch(origin)
92
+ }, ROOT_CONTEXT);
93
+ for (const node of buildIntervalTree(records)) emitNode(tracer, node, trace.setSpan(ROOT_CONTEXT, root));
94
+ root.end(toEpoch(end));
95
+ }
96
+ /**
97
+ * Discards buffered records and stops observing. Used between tests.
98
+ */
99
+ reset() {
100
+ this.fDetach?.();
101
+ this.fDetach = null;
102
+ this.fRecords = [];
103
+ this.fEmitted = false;
104
+ }
105
+ };
106
+ /**
107
+ * The process-wide recorder.
108
+ *
109
+ * Bootstrap profiling has to start during config load, before any container or
110
+ * DI-resolved service exists, so this cannot live in the container.
111
+ */
112
+ const bootstrapRecorder = new BootstrapRecorder();
113
+ /**
114
+ * Emits one construction span and everything nested inside it.
115
+ *
116
+ * @param tracer - Tracer the span is created on
117
+ * @param node - The construction to emit
118
+ * @param parent - Context carrying the parent span
119
+ */
120
+ function emitNode(tracer, node, parent) {
121
+ const { record } = node;
122
+ const span = tracer.startSpan(record.name, {
123
+ kind: SpanKind.INTERNAL,
124
+ startTime: toEpoch(record.start),
125
+ attributes: {
126
+ [VERCUBE_DI_KEY]: record.name,
127
+ [VERCUBE_DI_KIND]: toKind(record.type),
128
+ "vercube.di.context": record.context
129
+ }
130
+ }, parent);
131
+ const childContext = trace.setSpan(parent, span);
132
+ for (const child of node.children) emitNode(tracer, child, childContext);
133
+ span.end(toEpoch(record.end));
134
+ }
135
+ /**
136
+ * Rebuilds the construction call tree from nested intervals.
137
+ *
138
+ * The container reports a flat stream of completed constructions, but a
139
+ * construction that finished inside another one was nested in it, which is
140
+ * enough to recover the tree.
141
+ *
142
+ * @param records - Flat records in emission order
143
+ * @returns The roots of the reconstructed tree
144
+ */
145
+ function buildIntervalTree(records) {
146
+ const sorted = [...records].sort((a, b) => a.start - b.start || b.end - a.end);
147
+ const roots = [];
148
+ const stack = [];
149
+ for (const record of sorted) {
150
+ const node = {
151
+ record,
152
+ children: []
153
+ };
154
+ while (stack.length > 0 && stack.at(-1).record.end < record.end) stack.pop();
155
+ if (stack.length === 0) roots.push(node);
156
+ else stack.at(-1).children.push(node);
157
+ stack.push(node);
158
+ }
159
+ return roots;
160
+ }
161
+ /**
162
+ * Converts a `performance.now()` reading into epoch milliseconds.
163
+ *
164
+ * @param value - Monotonic timestamp
165
+ * @returns Epoch milliseconds
166
+ */
167
+ function toEpoch(value) {
168
+ return performance.timeOrigin + value;
169
+ }
170
+ /**
171
+ * Names a binding's factory type.
172
+ *
173
+ * @param type - Factory type recorded by the container
174
+ * @returns The kind name
175
+ */
176
+ function toKind(type) {
177
+ if (type === IOC.ServiceFactoryType.CLASS_SINGLETON) return "singleton";
178
+ return type === IOC.ServiceFactoryType.CLASS ? "transient" : "instance";
179
+ }
180
+ //#endregion
181
+ //#region src/Common/BodyCapture.ts
182
+ /** Content types whose bodies are safe to decode as text. */
183
+ const TEXT_CONTENT_TYPES = [
184
+ "application/json",
185
+ "application/ld+json",
186
+ "application/x-www-form-urlencoded",
187
+ "application/xml",
188
+ "application/javascript",
189
+ "application/graphql",
190
+ "text/",
191
+ "+json",
192
+ "+xml"
193
+ ];
194
+ /** Default cap on how much of a body is kept, in bytes. */
195
+ const DEFAULT_MAX_BODY_BYTES = 65536;
196
+ /** Span event name carrying the request body. */
197
+ const REQUEST_BODY_EVENT = "http.request.body";
198
+ /** Span event name carrying the response body. */
199
+ const RESPONSE_BODY_EVENT = "http.response.body";
200
+ /**
201
+ * Starts reading the incoming request body.
202
+ *
203
+ * The clone has to be taken **before** the application reads the stream, so
204
+ * this must be called before the handler runs, not from the span's settle
205
+ * callback.
206
+ *
207
+ * @param request - The incoming request
208
+ * @param maxBytes - Cap on the kept prefix
209
+ * @returns The pending preview, or undefined when there is no body worth reading
210
+ */
211
+ function captureRequestBody(request, maxBytes) {
212
+ if (!request.body || request.method === "GET" || request.method === "HEAD") return;
213
+ return readBody(request.clone(), request.headers, maxBytes);
214
+ }
215
+ /**
216
+ * Starts reading the outgoing response body.
217
+ *
218
+ * Must be called synchronously once the response exists: after the runtime
219
+ * starts writing it to the socket there is nothing left to tee.
220
+ *
221
+ * @param response - The outgoing response
222
+ * @param maxBytes - Cap on the kept prefix
223
+ * @returns The pending preview, or undefined when there is no body
224
+ */
225
+ function captureResponseBody(response, maxBytes) {
226
+ if (!response.body) return;
227
+ const contentType = response.headers.get("content-type");
228
+ if (contentType?.includes("text/event-stream")) return Promise.resolve(omitted(contentType, "streaming"));
229
+ return readBody(response.clone(), response.headers, maxBytes);
230
+ }
231
+ /**
232
+ * Records a captured body as a span event.
233
+ *
234
+ * @param span - The span to annotate
235
+ * @param name - Event name
236
+ * @param preview - The captured body
237
+ */
238
+ function addBodyEvent(span, name, preview) {
239
+ span.addEvent(name, {
240
+ "body.content_type": preview.contentType ?? void 0,
241
+ "body.size": preview.size,
242
+ "body.truncated": preview.truncated,
243
+ "body.omitted": preview.omitted,
244
+ "body.text": preview.text
245
+ });
246
+ }
247
+ /**
248
+ * Reads a message body into a capped, decoded preview.
249
+ *
250
+ * @param message - A clone of the request or response
251
+ * @param headers - Headers of the original message
252
+ * @param maxBytes - Cap on the kept prefix
253
+ * @returns The preview
254
+ */
255
+ async function readBody(message, headers, maxBytes) {
256
+ const contentType = headers.get("content-type");
257
+ const declared = Number.parseInt(headers.get("content-length") ?? "", 10);
258
+ if (Number.isFinite(declared) && declared > maxBytes) return omitted(contentType, "too-large", declared);
259
+ let bytes;
260
+ let size;
261
+ try {
262
+ ({bytes, size} = await readCapped(message, maxBytes));
263
+ } catch {
264
+ return omitted(contentType, "unreadable");
265
+ }
266
+ if (size === 0) return omitted(contentType, "empty");
267
+ if (!isTextual(contentType)) return omitted(contentType, "binary", size);
268
+ if (size > maxBytes) return {
269
+ contentType,
270
+ size,
271
+ text: new TextDecoder().decode(bytes).replace(/�+$/, ""),
272
+ truncated: true
273
+ };
274
+ try {
275
+ return {
276
+ contentType,
277
+ size,
278
+ text: new TextDecoder("utf-8", { fatal: true }).decode(bytes),
279
+ truncated: false
280
+ };
281
+ } catch {
282
+ return omitted(contentType, "binary", size);
283
+ }
284
+ }
285
+ /**
286
+ * Streams a body, keeping at most `maxBytes` of it in memory.
287
+ *
288
+ * The remainder is drained rather than cancelled, so the reported size stays
289
+ * exact and the clone's tee buffer does not grow unbounded.
290
+ *
291
+ * @param message - A clone of the request or response
292
+ * @param maxBytes - Cap on the kept prefix
293
+ * @returns The captured prefix and the total size
294
+ */
295
+ async function readCapped(message, maxBytes) {
296
+ const reader = message.body?.getReader();
297
+ if (!reader) return {
298
+ bytes: /* @__PURE__ */ new Uint8Array(0),
299
+ size: 0
300
+ };
301
+ const chunks = [];
302
+ let captured = 0;
303
+ let size = 0;
304
+ try {
305
+ for (;;) {
306
+ const { value, done } = await reader.read();
307
+ if (done) break;
308
+ size += value.byteLength;
309
+ if (captured < maxBytes) {
310
+ const slice = value.subarray(0, maxBytes - captured);
311
+ chunks.push(slice);
312
+ captured += slice.byteLength;
313
+ }
314
+ }
315
+ } finally {
316
+ reader.releaseLock();
317
+ }
318
+ const bytes = new Uint8Array(captured);
319
+ let offset = 0;
320
+ for (const chunk of chunks) {
321
+ bytes.set(chunk, offset);
322
+ offset += chunk.byteLength;
323
+ }
324
+ return {
325
+ bytes,
326
+ size
327
+ };
328
+ }
329
+ /**
330
+ * Whether a body of this content type should be decoded as text.
331
+ *
332
+ * @param contentType - Declared content type, when any
333
+ * @returns True for textual bodies
334
+ */
335
+ function isTextual(contentType) {
336
+ if (!contentType) return true;
337
+ const normalized = contentType.toLowerCase();
338
+ return TEXT_CONTENT_TYPES.some((candidate) => normalized.includes(candidate));
339
+ }
340
+ /**
341
+ * Builds a preview that carries no text, only the reason.
342
+ *
343
+ * @param contentType - Declared content type, when known
344
+ * @param reason - Why the body is not shown
345
+ * @param size - Body size in bytes, when known
346
+ * @returns The placeholder preview
347
+ */
348
+ function omitted(contentType, reason, size = 0) {
349
+ return {
350
+ contentType,
351
+ size,
352
+ truncated: false,
353
+ omitted: reason
354
+ };
355
+ }
356
+ //#endregion
357
+ //#region src/Common/HeaderCapture.ts
358
+ /**
359
+ * Headers whose values are never recorded, whatever the configuration says.
360
+ *
361
+ * A recorded `Authorization` header is a leaked credential the moment the trace
362
+ * leaves the process, and an inspector screenshot is enough. The names are
363
+ * still recorded so it stays visible that the header was present.
364
+ */
365
+ const ALWAYS_REDACTED = /* @__PURE__ */ new Set([
366
+ "authorization",
367
+ "proxy-authorization",
368
+ "cookie",
369
+ "set-cookie",
370
+ "x-api-key",
371
+ "x-auth-token",
372
+ "x-csrf-token"
373
+ ]);
374
+ /** Placeholder written in place of a withheld value. */
375
+ const REDACTED = "<redacted>";
376
+ /** Span event name carrying the request headers. */
377
+ const REQUEST_HEADERS_EVENT = "http.request.headers";
378
+ /** Span event name carrying the response headers. */
379
+ const RESPONSE_HEADERS_EVENT = "http.response.headers";
380
+ /**
381
+ * Records a message's headers as a span event.
382
+ *
383
+ * Recorded as one event with one attribute per header rather than as span
384
+ * attributes, so they stay grouped and cannot collide with the semantic
385
+ * conventions.
386
+ *
387
+ * @param span - The span to annotate
388
+ * @param name - Event name
389
+ * @param headers - The headers to record
390
+ * @param extraRedacted - Additional header names to withhold, lowercase
391
+ */
392
+ function addHeadersEvent(span, name, headers, extraRedacted) {
393
+ const attributes = {};
394
+ for (const [key, value] of headers) {
395
+ const lower = key.toLowerCase();
396
+ attributes[lower] = ALWAYS_REDACTED.has(lower) || extraRedacted.has(lower) ? REDACTED : value;
397
+ }
398
+ span.addEvent(name, attributes);
399
+ }
400
+ //#endregion
401
+ //#region src/Hooks/CoreTelemetryHooks.ts
402
+ /**
403
+ * Bucket boundaries for `http.server.request.duration`, in seconds.
404
+ * Taken from the OpenTelemetry HTTP semantic conventions.
405
+ */
406
+ const DURATION_BUCKETS = [
407
+ .005,
408
+ .01,
409
+ .025,
410
+ .05,
411
+ .075,
412
+ .1,
413
+ .25,
414
+ .5,
415
+ .75,
416
+ 1,
417
+ 2.5,
418
+ 5,
419
+ 7.5,
420
+ 10
421
+ ];
422
+ /** Milliseconds per second, for converting the monotonic clock. */
423
+ const MS_PER_SECOND$1 = 1e3;
424
+ /**
425
+ * The implementation core calls into.
426
+ *
427
+ * Turns every request into a `SERVER` span parented on the incoming
428
+ * `traceparent`, and records the standard request duration histogram.
429
+ */
430
+ var CoreTelemetryHooks = class {
431
+ /** The telemetry facade used for tracing and propagation. */
432
+ fTelemetry;
433
+ /** Whether to read `traceparent` from incoming requests. */
434
+ fPropagation;
435
+ /** Whether to record the request duration histogram. */
436
+ fMetrics;
437
+ /** Cap on captured body bytes, or 0 when body capture is off. */
438
+ fBodyBytes;
439
+ /** Whether buffered bootstrap constructions still have to be replayed. */
440
+ fBootstrapPending;
441
+ /** Path prefixes that produce no telemetry. */
442
+ fExclude;
443
+ /** Extra header names to withhold, or null when headers are not captured. */
444
+ fRedactHeaders;
445
+ /** Lazily created duration histogram. */
446
+ fDuration;
447
+ /**
448
+ * @param telemetry - The telemetry facade
449
+ * @param options - Resolved telemetry options
450
+ */
451
+ constructor(telemetry, options) {
452
+ this.fTelemetry = telemetry;
453
+ this.fPropagation = options.propagation !== false;
454
+ this.fMetrics = options.metrics !== false;
455
+ this.fBodyBytes = resolveBodyBytes(options.spans?.bodies);
456
+ this.fBootstrapPending = options.spans?.di !== false;
457
+ this.fExclude = options.exclude ?? [];
458
+ this.fRedactHeaders = resolveHeaderRedaction(options.spans?.headers);
459
+ }
460
+ /** @inheritdoc */
461
+ server(spanContext, fn) {
462
+ if (this.fExclude.length > 0 && this.isExcluded(getRequestPathname(spanContext.request))) return fn();
463
+ if (this.fBootstrapPending) {
464
+ this.fBootstrapPending = false;
465
+ bootstrapRecorder.emit(this.fTelemetry.tracer);
466
+ }
467
+ const parent = this.fPropagation ? this.fTelemetry.extract(spanContext.request.headers) : context.active();
468
+ const attributes = toAttributes(spanContext);
469
+ const startedAt = this.fMetrics ? performance.now() : 0;
470
+ const requestBody = this.fBodyBytes > 0 ? captureRequestBody(spanContext.request, this.fBodyBytes) : void 0;
471
+ const redact = this.fRedactHeaders;
472
+ return runInSpan(this.fTelemetry.tracer, spanContext.name, {
473
+ kind: SpanKind.SERVER,
474
+ attributes
475
+ }, parent, (span) => {
476
+ if (redact) addHeadersEvent(span, REQUEST_HEADERS_EVENT, spanContext.request.headers, redact);
477
+ return fn();
478
+ }, this.fMetrics || this.fBodyBytes > 0 || redact !== null ? (span, value, error) => {
479
+ if (this.fMetrics) this.recordDuration(attributes, startedAt, value, error);
480
+ if (redact && value instanceof Response) addHeadersEvent(span, RESPONSE_HEADERS_EVENT, value.headers, redact);
481
+ return this.fBodyBytes > 0 ? this.attachBodies(span, requestBody, value) : void 0;
482
+ } : void 0);
483
+ }
484
+ /**
485
+ * Attaches the captured request and response bodies to the span.
486
+ *
487
+ * The response clone is taken here rather than later: once the runtime starts
488
+ * writing the response there is nothing left to tee.
489
+ *
490
+ * @param span - The server span
491
+ * @param requestBody - The pending request body capture, if any
492
+ * @param value - The value the request produced
493
+ * @returns A promise that settles once both bodies have been read
494
+ */
495
+ attachBodies(span, requestBody, value) {
496
+ const responseBody = value instanceof Response ? captureResponseBody(value, this.fBodyBytes) : void 0;
497
+ if (!requestBody && !responseBody) return;
498
+ return Promise.all([requestBody, responseBody]).then(([request, response]) => {
499
+ if (request) addBodyEvent(span, REQUEST_BODY_EVENT, request);
500
+ if (response) addBodyEvent(span, RESPONSE_BODY_EVENT, response);
501
+ });
502
+ }
503
+ /** @inheritdoc */
504
+ recordError(error) {
505
+ const span = trace.getActiveSpan();
506
+ if (span) failSpan(span, error);
507
+ }
508
+ /**
509
+ * Whether a path is excluded from telemetry.
510
+ *
511
+ * @param path - Request pathname
512
+ * @returns True when nothing should be recorded
513
+ */
514
+ isExcluded(path) {
515
+ return this.fExclude.some((prefix) => path === prefix || path.startsWith(`${prefix}/`));
516
+ }
517
+ /** @inheritdoc */
518
+ flush() {
519
+ return this.fTelemetry.flush();
520
+ }
521
+ /** @inheritdoc */
522
+ traceId() {
523
+ return trace.getActiveSpan()?.spanContext().traceId;
524
+ }
525
+ /**
526
+ * Records one observation of `http.server.request.duration`.
527
+ *
528
+ * @param attributes - The span attributes to derive metric attributes from
529
+ * @param startedAt - `performance.now()` taken when the request started
530
+ * @param value - The value the request produced, normally a `Response`
531
+ * @param error - The thrown value, when the request failed
532
+ */
533
+ recordDuration(attributes, startedAt, value, error) {
534
+ this.fDuration ??= this.fTelemetry.meter.createHistogram("http.server.request.duration", {
535
+ description: "Duration of HTTP server requests.",
536
+ unit: "s",
537
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS }
538
+ });
539
+ const metricAttributes = {
540
+ [HTTP_REQUEST_METHOD]: attributes[HTTP_REQUEST_METHOD],
541
+ [URL_SCHEME]: attributes[URL_SCHEME]
542
+ };
543
+ if (attributes["http.route"] !== void 0) metricAttributes[HTTP_ROUTE] = attributes[HTTP_ROUTE];
544
+ if (value instanceof Response) metricAttributes[HTTP_RESPONSE_STATUS_CODE] = value.status;
545
+ else if (error !== void 0) metricAttributes[ERROR_TYPE] = errorType(error);
546
+ this.fDuration.record((performance.now() - startedAt) / MS_PER_SECOND$1, metricAttributes);
547
+ }
548
+ };
549
+ /**
550
+ * Builds the span attributes for a request.
551
+ *
552
+ * @param spanContext - Request and route metadata
553
+ * @returns The span attributes
554
+ */
555
+ function toAttributes(spanContext) {
556
+ const { request } = spanContext;
557
+ const attributes = {
558
+ [HTTP_REQUEST_METHOD]: request.method,
559
+ [URL_PATH]: getRequestPathname(request),
560
+ [URL_SCHEME]: request.url.startsWith("https") ? "https" : "http"
561
+ };
562
+ const query = getRequestSearch(request);
563
+ if (query.length > 1) attributes[URL_QUERY] = redactQuery(query.slice(1));
564
+ const host = request.headers.get("host");
565
+ if (host) {
566
+ const separator = host.lastIndexOf(":");
567
+ if (separator === -1) attributes[SERVER_ADDRESS] = host;
568
+ else {
569
+ attributes[SERVER_ADDRESS] = host.slice(0, separator);
570
+ attributes[SERVER_PORT] = Number(host.slice(separator + 1)) || void 0;
571
+ }
572
+ }
573
+ const userAgent = request.headers.get("user-agent");
574
+ if (userAgent) attributes[USER_AGENT_ORIGINAL] = userAgent;
575
+ if (spanContext.route !== void 0) attributes[HTTP_ROUTE] = spanContext.route;
576
+ if (spanContext.controller !== void 0) attributes[VERCUBE_CONTROLLER] = spanContext.controller;
577
+ if (spanContext.handler !== void 0) attributes[VERCUBE_HANDLER] = spanContext.handler;
578
+ return attributes;
579
+ }
580
+ /**
581
+ * Rewrites a query string with credential-bearing values withheld.
582
+ *
583
+ * A secret in `?access_token=` is as sensitive as one in an `Authorization`
584
+ * header, and it ends up in traces, downloadable snapshots and any exporter
585
+ * that shares the pipeline. The same name matching used for configuration
586
+ * values decides what to withhold.
587
+ *
588
+ * @param query - The raw query string, without the leading `?`
589
+ * @returns The query string with secret values replaced
590
+ */
591
+ function redactQuery(query) {
592
+ const params = new URLSearchParams(query);
593
+ let redacted = false;
594
+ for (const key of new Set(params.keys())) if (isSecretKey(key)) {
595
+ params.set(key, "<redacted>");
596
+ redacted = true;
597
+ }
598
+ return redacted ? params.toString() : query;
599
+ }
600
+ /**
601
+ * Resolves the body capture cap from the option.
602
+ *
603
+ * @param bodies - The `spans.bodies` option
604
+ * @returns The cap in bytes, or 0 when capture is off
605
+ */
606
+ function resolveBodyBytes(bodies) {
607
+ if (!bodies) return 0;
608
+ return bodies === true ? DEFAULT_MAX_BODY_BYTES : bodies.maxBytes ?? 65536;
609
+ }
610
+ /**
611
+ * Resolves the header capture setting.
612
+ *
613
+ * @param headers - The `spans.headers` option
614
+ * @returns Extra names to withhold, or null when headers are not captured
615
+ */
616
+ function resolveHeaderRedaction(headers) {
617
+ if (!headers) return null;
618
+ return new Set((headers === true ? [] : headers.redact ?? []).map((name) => name.toLowerCase()));
619
+ }
620
+ //#endregion
621
+ //#region src/Hooks/TraceCorrelation.ts
622
+ /** Plugin name, used by evlog for de-duplication. */
623
+ const TRACE_CORRELATION_PLUGIN = "vercube:trace-correlation";
624
+ /** Plugin name of the OTLP log drain. */
625
+ const OTLP_LOGS_PLUGIN = "vercube:otlp-logs";
626
+ /**
627
+ * Ids of the span active right now, shaped the way `evlog/otlp` expects.
628
+ *
629
+ * @returns The trace and span ids, or undefined outside a span
630
+ */
631
+ function activeIds() {
632
+ const spanContext = trace.getActiveSpan()?.spanContext();
633
+ return spanContext ? {
634
+ traceId: spanContext.traceId,
635
+ spanId: spanContext.spanId
636
+ } : void 0;
637
+ }
638
+ /**
639
+ * The evlog plugin half of the correlation, covering request wide events.
640
+ *
641
+ * evlog also ships `createTraceContextEnricher()`, which reads the inbound
642
+ * `traceparent` header. That is not enough: a request without one still has an
643
+ * in-process trace, and an event emitted inside a nested span belongs to that
644
+ * span rather than the request's. This reads whatever span is actually active.
645
+ *
646
+ * @returns The evlog plugin
647
+ */
648
+ function createTraceCorrelationPlugin() {
649
+ return enricherPlugin(TRACE_CORRELATION_PLUGIN, ({ event }) => {
650
+ Object.assign(event, activeIds());
651
+ });
652
+ }
653
+ /**
654
+ * Makes every log line carry the ids of the span it was written under.
655
+ *
656
+ * Two mechanisms are needed because evlog runs `enrich` only for request wide
657
+ * events - a plain `logger.info()` never passes through it - while a context
658
+ * provider covers exactly the calls that `enrich` misses. Registering both is
659
+ * what makes correlation hold for every log line rather than most of them.
660
+ *
661
+ * @param logger - The application logger
662
+ * @returns A function that removes the correlation again
663
+ */
664
+ function installTraceCorrelation(logger) {
665
+ logger.addPlugin(createTraceCorrelationPlugin());
666
+ return logger.addContextProvider(activeIds);
667
+ }
668
+ /**
669
+ * Ships every log event to an OTLP collector, correlated with its trace.
670
+ *
671
+ * evlog's OTLP adapter already emits `traceId` / `spanId` on the log record,
672
+ * and {@link installTraceCorrelation} is what fills them in, so logs and spans
673
+ * land in the same backend already joined.
674
+ *
675
+ * Batching, retry and backoff come from evlog's drain pipeline rather than a
676
+ * hand-rolled queue.
677
+ *
678
+ * @param logger - The application logger
679
+ * @param options - Endpoint and batching settings
680
+ * @returns Flushes anything still buffered; call it on shutdown
681
+ */
682
+ async function installOtlpLogs(logger, options = {}) {
683
+ const { createDrainPipeline, createOTLPDrain } = await import("@vercube/logger/otlp");
684
+ const drain = createDrainPipeline({ batch: {
685
+ size: 50,
686
+ intervalMs: 5e3
687
+ } })(createOTLPDrain(options.endpoint ? {
688
+ endpoint: options.endpoint,
689
+ headers: options.headers
690
+ } : {}));
691
+ logger.addDrain(OTLP_LOGS_PLUGIN, drain);
692
+ return () => drain.flush();
693
+ }
694
+ //#endregion
695
+ //#region src/Metrics/ProcessMetrics.ts
696
+ /**
697
+ * Resolution of the event loop delay histogram, in milliseconds.
698
+ *
699
+ * Also the floor subtracted from every reading: `monitorEventLoopDelay`
700
+ * schedules a timer every `resolution` ms and records how late it fired, so an
701
+ * idle loop reports roughly one resolution rather than zero. Without the
702
+ * subtraction a healthy server permanently claims ~10 ms of lag and any alert
703
+ * on it cries wolf.
704
+ */
705
+ const LOOP_RESOLUTION_MS = 10;
706
+ /** Nanoseconds per millisecond. */
707
+ const NS_PER_MS = 1e6;
708
+ /** Milliseconds per second. */
709
+ const MS_PER_SECOND = 1e3;
710
+ /** Microseconds per millisecond, for the CPU counter. */
711
+ const US_PER_MS = 1e3;
712
+ /**
713
+ * Registers observable instruments describing the Node.js process.
714
+ *
715
+ * Everything here is an *observable* instrument, so nothing is measured until a
716
+ * metric reader collects: with no reader registered the callbacks never run and
717
+ * the event loop monitor is the only cost. That is the same "only sample while
718
+ * someone is watching" property the devtools sampler had, without a bespoke
719
+ * timer.
720
+ *
721
+ * @param telemetry - The telemetry facade whose meter the instruments belong to
722
+ * @returns A function that unregisters the callback and stops the loop monitor
723
+ */
724
+ function installProcessMetrics(telemetry) {
725
+ const meter = telemetry.meter;
726
+ const cpu = meter.createObservableGauge("process.cpu.utilization", {
727
+ description: "Process CPU usage since the previous collection, as a fraction of one core.",
728
+ unit: "1",
729
+ valueType: ValueType.DOUBLE
730
+ });
731
+ const memory = meter.createObservableGauge("process.memory.usage", {
732
+ description: "Resident set size of the process.",
733
+ unit: "By",
734
+ valueType: ValueType.INT
735
+ });
736
+ const heapUsed = meter.createObservableGauge("v8js.memory.heap.used", {
737
+ description: "Heap memory currently in use.",
738
+ unit: "By",
739
+ valueType: ValueType.INT
740
+ });
741
+ const heapLimit = meter.createObservableGauge("v8js.memory.heap.limit", {
742
+ description: "Maximum heap size V8 will grow to.",
743
+ unit: "By",
744
+ valueType: ValueType.INT
745
+ });
746
+ const loopDelay = meter.createObservableGauge("nodejs.eventloop.delay.mean", {
747
+ description: "Mean event loop delay since the previous collection, with the sampling floor removed.",
748
+ unit: "s",
749
+ valueType: ValueType.DOUBLE
750
+ });
751
+ const loopDelayP99 = meter.createObservableGauge("nodejs.eventloop.delay.p99", {
752
+ description: "99th percentile event loop delay since the previous collection.",
753
+ unit: "s",
754
+ valueType: ValueType.DOUBLE
755
+ });
756
+ const loopUtilization = meter.createObservableGauge("nodejs.eventloop.utilization", {
757
+ description: "Fraction of time the event loop was busy since the previous collection.",
758
+ unit: "1",
759
+ valueType: ValueType.DOUBLE
760
+ });
761
+ const handles = meter.createObservableGauge("nodejs.process.handles", {
762
+ description: "Handles and requests keeping the process alive.",
763
+ unit: "{handle}",
764
+ valueType: ValueType.INT
765
+ });
766
+ const sampler = new ProcessSampler();
767
+ const instruments = [
768
+ cpu,
769
+ memory,
770
+ heapUsed,
771
+ heapLimit,
772
+ loopDelay,
773
+ loopDelayP99,
774
+ loopUtilization,
775
+ handles
776
+ ];
777
+ const observe = (result) => {
778
+ const reading = sampler.read();
779
+ if (reading.cpu !== null) result.observe(cpu, reading.cpu);
780
+ result.observe(memory, reading.rss);
781
+ result.observe(heapUsed, reading.heapUsed);
782
+ if (reading.heapLimit !== null) result.observe(heapLimit, reading.heapLimit);
783
+ if (reading.loopDelayMeanSeconds !== null) {
784
+ result.observe(loopDelay, reading.loopDelayMeanSeconds);
785
+ result.observe(loopDelayP99, reading.loopDelayP99Seconds);
786
+ }
787
+ if (reading.loopUtilization !== null) result.observe(loopUtilization, reading.loopUtilization);
788
+ if (reading.handles !== null) result.observe(handles, reading.handles);
789
+ };
790
+ meter.addBatchObservableCallback(observe, instruments);
791
+ return () => {
792
+ meter.removeBatchObservableCallback(observe, instruments);
793
+ sampler.stop();
794
+ };
795
+ }
796
+ /**
797
+ * Reads process counters, turning the cumulative ones into per-collection rates.
798
+ */
799
+ var ProcessSampler = class {
800
+ /** CPU counter at the previous collection. */
801
+ fLastCpu = null;
802
+ /** Wall clock at the previous collection. */
803
+ fLastAt = Date.now();
804
+ /** Event loop utilisation at the previous collection. */
805
+ fLastElu = null;
806
+ /** Event loop delay histogram, reset after every reading. */
807
+ fLoop = null;
808
+ constructor() {
809
+ this.fLastCpu = safely(() => process.cpuUsage());
810
+ this.fLastElu = safely(() => performance$1.eventLoopUtilization());
811
+ this.fLoop = safely(() => {
812
+ const histogram = monitorEventLoopDelay({ resolution: LOOP_RESOLUTION_MS });
813
+ histogram.enable();
814
+ return histogram;
815
+ });
816
+ }
817
+ /**
818
+ * Takes one reading.
819
+ *
820
+ * @returns The current state of the process
821
+ */
822
+ read() {
823
+ const usage = process.memoryUsage();
824
+ return {
825
+ cpu: this.readCpu(),
826
+ rss: usage.rss,
827
+ heapUsed: usage.heapUsed,
828
+ heapLimit: safely(() => getHeapStatistics().heap_size_limit),
829
+ ...this.readLoop(),
830
+ handles: safely(() => countHandles())
831
+ };
832
+ }
833
+ /**
834
+ * Releases the event loop monitor.
835
+ */
836
+ stop() {
837
+ this.fLoop?.disable();
838
+ this.fLoop = null;
839
+ }
840
+ /**
841
+ * CPU time consumed since the previous reading, as a fraction of one core.
842
+ *
843
+ * @returns The utilisation, or null when the counter is unavailable
844
+ */
845
+ readCpu() {
846
+ const at = Date.now();
847
+ const current = safely(() => process.cpuUsage());
848
+ const previous = this.fLastCpu;
849
+ const elapsedMs = at - this.fLastAt;
850
+ this.fLastCpu = current;
851
+ this.fLastAt = at;
852
+ if (!current || !previous || elapsedMs <= 0) return null;
853
+ const window = elapsedMs * US_PER_MS;
854
+ return Math.max(0, (current.user - previous.user + (current.system - previous.system)) / window);
855
+ }
856
+ /**
857
+ * Event loop delay and utilisation since the previous reading.
858
+ *
859
+ * @returns The loop portion of a reading
860
+ */
861
+ readLoop() {
862
+ const histogram = this.fLoop;
863
+ const previous = this.fLastElu;
864
+ const current = safely(() => performance$1.eventLoopUtilization());
865
+ this.fLastElu = current;
866
+ if (!histogram) return {
867
+ loopDelayMeanSeconds: null,
868
+ loopDelayP99Seconds: null,
869
+ loopUtilization: null
870
+ };
871
+ const meanMs = aboveFloor(histogram.mean / NS_PER_MS);
872
+ const p99Ms = aboveFloor(histogram.percentile(99) / NS_PER_MS);
873
+ histogram.reset();
874
+ return {
875
+ loopDelayMeanSeconds: meanMs / MS_PER_SECOND,
876
+ loopDelayP99Seconds: p99Ms / MS_PER_SECOND,
877
+ loopUtilization: current && previous ? safely(() => performance$1.eventLoopUtilization(current, previous).utilization) ?? null : null
878
+ };
879
+ }
880
+ };
881
+ /**
882
+ * Removes the sampling floor from an event loop delay reading.
883
+ *
884
+ * @param value - Raw reading in milliseconds
885
+ * @returns The delay above the floor, never negative
886
+ */
887
+ function aboveFloor(value) {
888
+ return Number.isFinite(value) ? Math.max(0, value - LOOP_RESOLUTION_MS) : 0;
889
+ }
890
+ /**
891
+ * Counts the handles and requests keeping the process alive.
892
+ *
893
+ * @returns The count
894
+ */
895
+ function countHandles() {
896
+ const scope = process;
897
+ return (scope._getActiveHandles?.().length ?? 0) + (scope._getActiveRequests?.().length ?? 0);
898
+ }
899
+ /**
900
+ * Runs a counter read, turning an unsupported runtime into `null`.
901
+ *
902
+ * Bun and Deno do not implement every Node counter, and a missing gauge is
903
+ * better than a crashed collection.
904
+ *
905
+ * @param fn - The read to attempt
906
+ * @returns The value, or null when it threw
907
+ */
908
+ function safely(fn) {
909
+ try {
910
+ return fn();
911
+ } catch {
912
+ return null;
913
+ }
914
+ }
915
+ //#endregion
916
+ //#region src/Service/OtelTelemetry.ts
917
+ /**
918
+ * {@link Telemetry} implemented on the OpenTelemetry API.
919
+ *
920
+ * Everything here goes through `@opentelemetry/api`, never an SDK. With no
921
+ * `TracerProvider` registered the API returns non-recording spans, so an
922
+ * application that installs the plugin but no exporter pays almost nothing and
923
+ * still propagates trace context correctly.
924
+ */
925
+ var OtelTelemetry = class extends Telemetry {
926
+ /** Scope name reported for spans created through this instance. */
927
+ fScope;
928
+ /** Propagator used by {@link OtelTelemetry.inject} and {@link OtelTelemetry.extract}. */
929
+ fPropagator;
930
+ /** Callbacks run by {@link OtelTelemetry.flush}. */
931
+ fFlushers = [];
932
+ /**
933
+ * @param scope - Instrumentation scope name
934
+ * @param propagator - Trace context propagator
935
+ */
936
+ constructor(scope = INSTRUMENTATION_SCOPE, propagator = new W3CTraceContextPropagator()) {
937
+ super();
938
+ this.fScope = scope;
939
+ this.fPropagator = propagator;
940
+ }
941
+ /** @inheritdoc */
942
+ get tracer() {
943
+ return trace.getTracer(this.fScope);
944
+ }
945
+ /** @inheritdoc */
946
+ get meter() {
947
+ return metrics.getMeter(this.fScope);
948
+ }
949
+ /** @inheritdoc */
950
+ span(name, fn, options = {}) {
951
+ return runInSpan(this.tracer, name, options, context.active(), fn);
952
+ }
953
+ /** @inheritdoc */
954
+ activeSpan() {
955
+ return trace.getActiveSpan();
956
+ }
957
+ /** @inheritdoc */
958
+ get traceId() {
959
+ return this.activeSpan()?.spanContext().traceId;
960
+ }
961
+ /** @inheritdoc */
962
+ get spanId() {
963
+ return this.activeSpan()?.spanContext().spanId;
964
+ }
965
+ /** @inheritdoc */
966
+ inject(carrier) {
967
+ this.fPropagator.inject(context.active(), carrier, headersSetter);
968
+ }
969
+ /** @inheritdoc */
970
+ extract(headers) {
971
+ return this.fPropagator.extract(ROOT_CONTEXT, headers, headersGetter);
972
+ }
973
+ /** @inheritdoc */
974
+ onFlush(flush) {
975
+ this.fFlushers.push(flush);
976
+ }
977
+ /** @inheritdoc */
978
+ async flush() {
979
+ await Promise.allSettled(this.fFlushers.map((flush) => flush()));
980
+ }
981
+ };
982
+ //#endregion
983
+ //#region src/Plugins/TelemetryPlugin.ts
984
+ /**
985
+ * Activates OpenTelemetry instrumentation for the application.
986
+ *
987
+ * Register it in `vercube.config.ts`:
988
+ *
989
+ * ```ts
990
+ * export default defineConfig({
991
+ * telemetry: true,
992
+ * plugins: [TelemetryPlugin],
993
+ * });
994
+ * ```
995
+ *
996
+ * The plugin only wires the OpenTelemetry **API**: it registers a context
997
+ * manager and a W3C propagator, binds the {@link Telemetry} token and installs
998
+ * the hooks core calls into. Producing actual spans additionally requires a
999
+ * `TracerProvider`, which either the application registers through the standard
1000
+ * OpenTelemetry SDK, `@vercube/telemetry/sdk`, or `@vercube/devtools`.
1001
+ *
1002
+ * Options given at registration time win over the `telemetry` field of the
1003
+ * application config.
1004
+ */
1005
+ var TelemetryPlugin = class extends BasePlugin {
1006
+ /** @inheritdoc */
1007
+ name = "TelemetryPlugin";
1008
+ /**
1009
+ * Starts watching container construction.
1010
+ *
1011
+ * This runs while the config is still being loaded, which is the only phase
1012
+ * early enough: by the time `use()` runs the container has already built a
1013
+ * good part of the application. Registering the plugin through
1014
+ * `defineConfig({ plugins })` rather than `app.addPlugin()` is therefore what
1015
+ * makes bootstrap spans complete.
1016
+ *
1017
+ * @param config - The merged configuration
1018
+ * @param options - Options overriding the `telemetry` config field
1019
+ */
1020
+ configure(config, options) {
1021
+ const resolved = resolveTelemetryOptions({
1022
+ ...config,
1023
+ telemetry: {
1024
+ ...normalize(config.telemetry),
1025
+ ...options
1026
+ }
1027
+ });
1028
+ if (resolved.enabled && resolved.spans?.di !== false) bootstrapRecorder.install();
1029
+ }
1030
+ /**
1031
+ * Installs telemetry into the running application.
1032
+ *
1033
+ * @param app - The application
1034
+ * @param options - Options overriding the `telemetry` config field
1035
+ */
1036
+ use(app, options) {
1037
+ const resolved = resolveTelemetryOptions({
1038
+ ...app.config,
1039
+ telemetry: {
1040
+ ...normalize(app.config.telemetry),
1041
+ ...options
1042
+ }
1043
+ });
1044
+ if (!resolved.enabled) return;
1045
+ const container = app.container;
1046
+ const logger = container.getOptional(Logger);
1047
+ const registry = container.get(TelemetryRegistry);
1048
+ if (registry.enabled) {
1049
+ logger?.debug("TelemetryPlugin", "Telemetry is already installed, skipping");
1050
+ return;
1051
+ }
1052
+ let requestContext = container.getOptional(RequestContext);
1053
+ if (!requestContext) {
1054
+ container.bind(RequestContext);
1055
+ requestContext = container.get(RequestContext);
1056
+ }
1057
+ context.setGlobalContextManager(new VercubeContextManager(requestContext).enable());
1058
+ propagation.setGlobalPropagator(new W3CTraceContextPropagator());
1059
+ const telemetry = new OtelTelemetry(INSTRUMENTATION_SCOPE);
1060
+ container.bindInstance(Telemetry, telemetry);
1061
+ registry.install(new CoreTelemetryHooks(telemetry, resolved), resolved);
1062
+ if (resolved.spans?.di !== false) bootstrapRecorder.install();
1063
+ if (resolved.metrics !== false) installProcessMetrics(telemetry);
1064
+ telemetry.onFlush(async () => {
1065
+ await trace.getTracerProvider().forceFlush?.();
1066
+ });
1067
+ if (logger) installTraceCorrelation(logger);
1068
+ logger?.debug("TelemetryPlugin", "OpenTelemetry instrumentation installed");
1069
+ if (logger && resolved.logs) return installOtlpLogs(logger, { endpoint: resolved.endpoint }).then((flush) => {
1070
+ telemetry.onFlush(flush);
1071
+ });
1072
+ }
1073
+ };
1074
+ /**
1075
+ * Turns the shorthand `telemetry: boolean` form into an options object.
1076
+ *
1077
+ * @param value - The raw config value
1078
+ * @returns The equivalent options object
1079
+ */
1080
+ function normalize(value) {
1081
+ if (value === void 0) return {};
1082
+ return typeof value === "boolean" ? { enabled: value } : value;
1083
+ }
1084
+ //#endregion
1085
+ export { Telemetry, TelemetryPlugin };