@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/LICENSE +21 -0
- package/README.md +87 -0
- package/dist/Api.d.mts +1 -0
- package/dist/Api.mjs +2 -0
- package/dist/Attributes-QVHj8ZgV.mjs +48 -0
- package/dist/Attributes.d.mts +47 -0
- package/dist/Attributes.mjs +2 -0
- package/dist/Instrument.d.mts +132 -0
- package/dist/Instrument.mjs +103 -0
- package/dist/Otlp.d.mts +31 -0
- package/dist/Otlp.mjs +52 -0
- package/dist/Sdk.d.mts +156 -0
- package/dist/Sdk.mjs +229 -0
- package/dist/SpanUtils-t3NCeswB.mjs +147 -0
- package/dist/Testing.d.mts +53 -0
- package/dist/Testing.mjs +63 -0
- package/dist/VercubeContextManager-n4jHlB-w.mjs +197 -0
- package/dist/index.d.mts +125 -0
- package/dist/index.mjs +1085 -0
- package/package.json +99 -0
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 };
|