@setu-ts/kernel 0.0.0-stage → 0.10.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.
Files changed (79) hide show
  1. package/AGENT_CARD.md +47 -0
  2. package/LICENSE +21 -0
  3. package/README.md +152 -2
  4. package/dist/application/application.d.ts +174 -0
  5. package/dist/application/application.d.ts.map +1 -0
  6. package/dist/application/application.js +1905 -0
  7. package/dist/application/application.js.map +1 -0
  8. package/dist/application/inject-body.d.ts +58 -0
  9. package/dist/application/inject-body.d.ts.map +1 -0
  10. package/dist/application/inject-body.js +82 -0
  11. package/dist/application/inject-body.js.map +1 -0
  12. package/dist/context/request-context.d.ts +32 -0
  13. package/dist/context/request-context.d.ts.map +1 -0
  14. package/dist/context/request-context.js +136 -0
  15. package/dist/context/request-context.js.map +1 -0
  16. package/dist/context/response.d.ts +60 -0
  17. package/dist/context/response.d.ts.map +1 -0
  18. package/dist/context/response.js +193 -0
  19. package/dist/context/response.js.map +1 -0
  20. package/dist/diagnostics/buffer.d.ts +143 -0
  21. package/dist/diagnostics/buffer.d.ts.map +1 -0
  22. package/dist/diagnostics/buffer.js +190 -0
  23. package/dist/diagnostics/buffer.js.map +1 -0
  24. package/dist/diagnostics/collector.d.ts +284 -0
  25. package/dist/diagnostics/collector.d.ts.map +1 -0
  26. package/dist/diagnostics/collector.js +810 -0
  27. package/dist/diagnostics/collector.js.map +1 -0
  28. package/dist/diagnostics/projection.d.ts +153 -0
  29. package/dist/diagnostics/projection.d.ts.map +1 -0
  30. package/dist/diagnostics/projection.js +226 -0
  31. package/dist/diagnostics/projection.js.map +1 -0
  32. package/dist/index.d.ts +12 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +11 -0
  35. package/dist/index.js.map +1 -0
  36. package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
  37. package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
  38. package/dist/lifecycle/lifecycle-manager.js +206 -0
  39. package/dist/lifecycle/lifecycle-manager.js.map +1 -0
  40. package/dist/pipeline/execute-chain.d.ts +71 -0
  41. package/dist/pipeline/execute-chain.d.ts.map +1 -0
  42. package/dist/pipeline/execute-chain.js +104 -0
  43. package/dist/pipeline/execute-chain.js.map +1 -0
  44. package/dist/pipeline/middleware-pipeline.d.ts +68 -0
  45. package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
  46. package/dist/pipeline/middleware-pipeline.js +82 -0
  47. package/dist/pipeline/middleware-pipeline.js.map +1 -0
  48. package/dist/registry/plugin-resolver.d.ts +42 -0
  49. package/dist/registry/plugin-resolver.d.ts.map +1 -0
  50. package/dist/registry/plugin-resolver.js +140 -0
  51. package/dist/registry/plugin-resolver.js.map +1 -0
  52. package/dist/registry/service-registry.d.ts +110 -0
  53. package/dist/registry/service-registry.d.ts.map +1 -0
  54. package/dist/registry/service-registry.js +197 -0
  55. package/dist/registry/service-registry.js.map +1 -0
  56. package/dist/router/route-matcher.d.ts +85 -0
  57. package/dist/router/route-matcher.d.ts.map +1 -0
  58. package/dist/router/route-matcher.js +105 -0
  59. package/dist/router/route-matcher.js.map +1 -0
  60. package/dist/router/router.d.ts +130 -0
  61. package/dist/router/router.d.ts.map +1 -0
  62. package/dist/router/router.js +350 -0
  63. package/dist/router/router.js.map +1 -0
  64. package/package.json +49 -3
  65. package/src/application/application.ts +2326 -0
  66. package/src/application/inject-body.ts +130 -0
  67. package/src/context/request-context.ts +169 -0
  68. package/src/context/response.ts +246 -0
  69. package/src/diagnostics/buffer.ts +243 -0
  70. package/src/diagnostics/collector.ts +1030 -0
  71. package/src/diagnostics/projection.ts +322 -0
  72. package/src/index.ts +20 -0
  73. package/src/lifecycle/lifecycle-manager.ts +282 -0
  74. package/src/pipeline/execute-chain.ts +162 -0
  75. package/src/pipeline/middleware-pipeline.ts +129 -0
  76. package/src/registry/plugin-resolver.ts +185 -0
  77. package/src/registry/service-registry.ts +262 -0
  78. package/src/router/route-matcher.ts +134 -0
  79. package/src/router/router.ts +485 -0
@@ -0,0 +1,1030 @@
1
+ /**
2
+ * Diagnostics collector — the kernel-owned writer and the public pull-only
3
+ * reader behind `IApplication.diagnostics`.
4
+ *
5
+ * Ownership is the whole design: only the kernel implementation writes here,
6
+ * at its own registration and execution boundaries, projecting bounded
7
+ * primitives captured once at the owning mutation. The reader offers exactly
8
+ * `snapshot()` and `read()` — neither can register a callback, invoke
9
+ * application code, resolve a service, or mutate state — so an external
10
+ * reader can be slow, stop polling, or throw after reading without any effect
11
+ * on the application.
12
+ *
13
+ * Every mutation and execution call site passes through {@linkcode
14
+ * DiagnosticsCollector.safeObserve}: an inspection failure must never change a
15
+ * response, a lifecycle result, or a startup error. An event-capture failure
16
+ * drops the record, increments a value-free counter, and disables further
17
+ * event capture; a topology-capture failure drops that entry and marks the
18
+ * snapshot truncated.
19
+ *
20
+ * @module
21
+ */
22
+ import type {
23
+ DiagnosticsBatch,
24
+ DiagnosticsEdge,
25
+ DiagnosticsEdgeKind,
26
+ DiagnosticsEvent,
27
+ DiagnosticsEventKind,
28
+ DiagnosticsEventOutcome,
29
+ DiagnosticsEventStage,
30
+ DiagnosticsFailureCode,
31
+ DiagnosticsNode,
32
+ DiagnosticsNodeKind,
33
+ DiagnosticsSnapshot,
34
+ DiagnosticsSnapshotState,
35
+ HttpMethod,
36
+ IDiagnosticsSource,
37
+ } from '@setu-ts/common';
38
+
39
+ import type { RouteRegistrationEvent } from '../router/router.ts';
40
+ import type { RegistryDiagnosticEvent } from '../registry/service-registry.ts';
41
+
42
+ import {
43
+ DiagnosticsEventRing,
44
+ eventWithinByteCap,
45
+ MAX_EVENT_FIELD_LENGTH,
46
+ validateReadCursor,
47
+ } from './buffer.ts';
48
+ import {
49
+ applySnapshotBudget,
50
+ approvedLabel,
51
+ boundedPluginVersion,
52
+ MAX_EDGES,
53
+ MAX_NODES,
54
+ projectHttpMethod,
55
+ saturatingNext,
56
+ } from './projection.ts';
57
+ import type { DiagnosticsLabelAllowlists } from './projection.ts';
58
+
59
+ /** Capture-time description of one plugin, taken at its registration boundary. */
60
+ export interface PluginRegistrationInfo {
61
+ /** The plugin's declared `name`. */
62
+ readonly name: string;
63
+ /** The plugin's declared `version`, emitted only when it passes the bounded grammar. */
64
+ readonly version: string;
65
+ /** Declared `provides` tokens. */
66
+ readonly provides: readonly string[];
67
+ /** Declared `requires` tokens. */
68
+ readonly requires: readonly string[];
69
+ /** Declared `optionalDependencies` tokens. */
70
+ readonly optionalDependencies: readonly string[];
71
+ /** Declared `consumes` tokens. */
72
+ readonly consumes: readonly string[];
73
+ }
74
+
75
+ /** Capture-time description of one global middleware stage, in execution order. */
76
+ export interface MiddlewareCompiledDescriptor {
77
+ readonly name: string;
78
+ readonly priority: number;
79
+ /** 1-based execution position in the stable priority sort. */
80
+ readonly position: number;
81
+ }
82
+
83
+ /** An operation allocated at entry, linking later records to their parent. */
84
+ export interface DiagnosticsOperation {
85
+ readonly id: string;
86
+ readonly startedAtMs: number | null;
87
+ }
88
+
89
+ /** Parts of one completion record the collector turns into a stored event. */
90
+ export interface CompletionRecordParts {
91
+ readonly kind: DiagnosticsEventKind;
92
+ readonly stage: DiagnosticsEventStage;
93
+ readonly operationId: string;
94
+ readonly parentOperationId: string | null;
95
+ readonly nodeId: string | null;
96
+ readonly outcome: DiagnosticsEventOutcome;
97
+ readonly startedAtMs: number | null;
98
+ readonly durationMs: number | null;
99
+ readonly statusCode?: number;
100
+ /** Whether the record may carry validated trace/span identifiers. */
101
+ readonly withTrace?: boolean;
102
+ }
103
+
104
+ /** The validated telemetry identifiers a record may carry. */
105
+ interface TraceIdentifiers {
106
+ readonly traceId?: string;
107
+ readonly spanId?: string;
108
+ }
109
+
110
+ /** The slice of `ITelemetryService` the collector reads, kept structural. */
111
+ interface TraceReadingTelemetry {
112
+ activeSpanContext?(): { readonly traceId: string; readonly spanId: string } | undefined;
113
+ }
114
+
115
+ interface NodeRecord {
116
+ readonly id: string;
117
+ readonly kind: DiagnosticsNodeKind;
118
+ readonly label?: string;
119
+ readonly version?: string;
120
+ readonly method?: HttpMethod;
121
+ readonly priority?: number;
122
+ readonly position?: number;
123
+ registered?: boolean;
124
+ }
125
+
126
+ interface EdgeRecord {
127
+ readonly from: string;
128
+ readonly to: string;
129
+ readonly kind: DiagnosticsEdgeKind;
130
+ }
131
+
132
+ const TRACE_ID_PATTERN = /^[0-9a-f]{32}$/;
133
+ const SPAN_ID_PATTERN = /^[0-9a-f]{16}$/;
134
+ const NON_ZERO = /[1-9a-f]/;
135
+
136
+ /**
137
+ * Recursively freezes a DTO so a reader holding it can observe nothing that
138
+ * happens afterwards.
139
+ */
140
+ function deepFreeze<T>(value: T): T {
141
+ if (value !== null && typeof value === 'object') {
142
+ for (const key of Object.keys(value as Record<string, unknown>)) {
143
+ deepFreeze((value as Record<string, unknown>)[key]);
144
+ }
145
+ Object.freeze(value);
146
+ }
147
+ return value;
148
+ }
149
+
150
+ /**
151
+ * The collector. Instantiated ONLY when the application was created with
152
+ * `diagnostics` explicitly enabled; an omitted option never constructs one.
153
+ * @since 0.8.0
154
+ */
155
+ export class DiagnosticsCollector implements IDiagnosticsSource {
156
+ readonly #labels: DiagnosticsLabelAllowlists;
157
+ readonly #ring = new DiagnosticsEventRing();
158
+ readonly #nodes: NodeRecord[] = [];
159
+ readonly #edges: EdgeRecord[] = [];
160
+ readonly #edgeKeys = new Set<string>();
161
+ readonly #pluginNodesByName = new Map<string, NodeRecord>();
162
+ readonly #capabilityNodesByToken = new Map<string, NodeRecord>();
163
+ readonly #routeNodeIds = new Map<number, NodeRecord>();
164
+ readonly #routeMiddlewareNodes = new Map<number, NodeRecord[]>();
165
+ readonly #globalMiddlewareNodes: NodeRecord[] = [];
166
+ readonly #requestOperations = new WeakMap<object, DiagnosticsOperation>();
167
+ #cachedSnapshot: DiagnosticsSnapshot | undefined;
168
+ #snapshotDirty = true;
169
+ #state: DiagnosticsSnapshotState = 'created';
170
+ #failureCode: DiagnosticsFailureCode | null = null;
171
+ #topologyTruncated = false;
172
+ #droppedEvents = 0;
173
+ #eventCaptureDisabled = false;
174
+ #opCounter = 0;
175
+ #pluginCounter = 0;
176
+ #capabilityCounter = 0;
177
+ #routeCounter = 0;
178
+ #middlewareCounter = 0;
179
+ #instanceId: string | null = null;
180
+ #runtimeInitialized = false;
181
+ #clock: (() => number | null) | undefined;
182
+ #telemetryReader: (() => TraceReadingTelemetry | undefined) | undefined;
183
+
184
+ constructor(labels: DiagnosticsLabelAllowlists) {
185
+ this.#labels = labels;
186
+ }
187
+
188
+ // ---------------------------------------------------------------------------
189
+ // Reader — the whole public surface (IDiagnosticsSource)
190
+ // ---------------------------------------------------------------------------
191
+
192
+ /** @inheritDoc */
193
+ snapshot(): DiagnosticsSnapshot {
194
+ if (this.#cachedSnapshot !== undefined && !this.#snapshotDirty) {
195
+ return this.#cachedSnapshot;
196
+ }
197
+ // The budget applies to the FINAL DTO: entries are omitted (with their
198
+ // edges) until the exact compact UTF-8 length fits — the trim is the pure
199
+ // `applySnapshotBudget` seam in projection.ts. M98b re-measures the same
200
+ // bytes before signing, so no accepted snapshot can exceed the cap.
201
+ const frozen = deepFreeze(
202
+ applySnapshotBudget(
203
+ {
204
+ instanceId: this.#instanceId,
205
+ state: this.#state,
206
+ failureCode: this.#failureCode,
207
+ droppedEvents: this.#droppedEvents,
208
+ },
209
+ this.#nodes.map(nodeToDto),
210
+ this.#edges.map(edgeToDto),
211
+ this.#topologyTruncated,
212
+ ),
213
+ );
214
+ this.#cachedSnapshot = frozen;
215
+ this.#snapshotDirty = false;
216
+ return frozen;
217
+ }
218
+
219
+ /** @inheritDoc */
220
+ read(after: number, limit?: number): DiagnosticsBatch {
221
+ // A closed ring keeps its sequence counter (`clear()` never rewinds it), so
222
+ // the ordinary "beyond the sequence" refusal still admits every cursor a
223
+ // reader could have been issued before shutdown — and refuses one it could
224
+ // not, rather than echoing an invented cursor back as `next`.
225
+ const cursor = validateReadCursor(after, limit, this.#ring.lastSequence);
226
+ if (this.#ring.closed) {
227
+ return deepFreeze({
228
+ version: 1 as const,
229
+ instanceId: this.#instanceId,
230
+ events: Object.freeze([]),
231
+ next: after,
232
+ lost: 0,
233
+ closed: true,
234
+ });
235
+ }
236
+ // The window starts at the first RETAINED sequence past the cursor — a
237
+ // cursor parked behind an eviction receives the oldest retained records,
238
+ // with the skipped sequences reported as `lost`, never an empty page.
239
+ const start = Math.max(after + 1, this.#ring.firstSequence);
240
+ const end = Math.min(this.#ring.lastSequence, start + cursor.limit - 1);
241
+ const events: DiagnosticsEvent[] = [];
242
+ for (let sequence = start; sequence <= end; sequence++) {
243
+ events.push(this.#ring.at(sequence)!);
244
+ }
245
+ const lost = events.length > 0 ? start - after - 1 : 0;
246
+ const next = events.length > 0 ? events[events.length - 1]!.sequence : after;
247
+ // Stored events are already frozen primitives-only objects: freezing the
248
+ // two arrays and the root is the complete deep freeze, without walking
249
+ // every record on a polling read.
250
+ return deepFreeze({
251
+ version: 1 as const,
252
+ instanceId: this.#instanceId,
253
+ events: Object.freeze(events),
254
+ next,
255
+ lost,
256
+ closed: false,
257
+ });
258
+ }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // State machine
262
+ // ---------------------------------------------------------------------------
263
+
264
+ /**
265
+ * Application start has begun. A start following a FAILED start resets the
266
+ * reader for the new attempt — the kernel rolls its own `#started` back so
267
+ * a start that failed before any plugin registered can be corrected and
268
+ * retried, and the reader must not keep
269
+ * reporting `failed` for a process that is starting (or later serving)
270
+ * again. The failed attempt's retained metadata was already cleared by
271
+ * `markStartupFailed`, so the reset recovers nothing sensitive; `closed`
272
+ * remains absolute (a stopped application never restarts its reader).
273
+ */
274
+ markStarting(): void {
275
+ if (this.#state === 'failed') {
276
+ this.#failureCode = null;
277
+ this.#clearRetained();
278
+ // Deliberate latch reset (not #transition): a NEW start() after a
279
+ // failed one is the kernel's supported retry, and only this method may
280
+ // leave `failed`.
281
+ this.#state = 'starting';
282
+ this.#snapshotDirty = true;
283
+ return;
284
+ }
285
+ this.#transition('starting');
286
+ }
287
+
288
+ /** Startup completed; the application is serving. */
289
+ markRunning(): void {
290
+ this.#transition('running');
291
+ }
292
+
293
+ /** Shutdown has begun; the application is about to refuse requests. */
294
+ markStopping(): void {
295
+ this.#transition('stopping');
296
+ }
297
+
298
+ /**
299
+ * Startup failed. Terminal: clears every retained node/edge/event buffer
300
+ * while preserving the caller's original error, so a reader sees only the
301
+ * coarse state, the failure code, and counters.
302
+ */
303
+ markStartupFailed(): void {
304
+ if (this.#state === 'failed' || this.#state === 'closed') {
305
+ return;
306
+ }
307
+ this.#failureCode = 'startup-failed';
308
+ this.#transition('failed');
309
+ this.#clearRetained();
310
+ }
311
+
312
+ /**
313
+ * Shutdown finished (successfully or not). Terminal, like
314
+ * {@linkcode markStartupFailed}: retained buffers are cleared regardless of
315
+ * the outcome.
316
+ *
317
+ * @param shutdownFailed - `true` when a shutdown-phase hook rejected
318
+ */
319
+ markClosed(shutdownFailed: boolean): void {
320
+ if (this.#state === 'failed' || this.#state === 'closed') {
321
+ return;
322
+ }
323
+ this.#failureCode = shutdownFailed ? 'shutdown-failed' : null;
324
+ this.#transition('closed');
325
+ this.#ring.close();
326
+ this.#clearRetained();
327
+ }
328
+
329
+ /**
330
+ * Records runtime availability: the monotonic origin starts here and the
331
+ * instance UUID is generated once, through the registered runtime. Both are
332
+ * guarded — a failing runtime reports as unavailable (`null` timings, `null`
333
+ * instanceId) rather than turning startup into a diagnostics fault. Before
334
+ * this call, every timing is `null` and none is ever fabricated.
335
+ *
336
+ * @param runtime - The registered runtime services
337
+ * @param telemetryReader - Resolves the telemetry service WITHOUT resolving
338
+ * a lazy factory; `undefined` identifiers are reported as absence
339
+ */
340
+ initializeRuntime(
341
+ runtime: { readonly uuid: () => string; readonly hrtime: () => number },
342
+ telemetryReader: (() => TraceReadingTelemetry | undefined) | undefined,
343
+ ): void {
344
+ // Idempotent: the FIRST runtime registration starts the epoch, and a
345
+ // later call (the step-4 fallback) cannot re-mint the identity.
346
+ if (this.#runtimeInitialized) {
347
+ return;
348
+ }
349
+ this.#runtimeInitialized = true;
350
+ try {
351
+ this.#instanceId = runtime.uuid();
352
+ } catch {
353
+ this.#instanceId = null;
354
+ }
355
+ try {
356
+ const origin = runtime.hrtime();
357
+ this.#clock = (): number | null => {
358
+ try {
359
+ return runtime.hrtime() - origin;
360
+ } catch {
361
+ return null;
362
+ }
363
+ };
364
+ } catch {
365
+ this.#clock = undefined;
366
+ }
367
+ this.#telemetryReader = telemetryReader;
368
+ this.#snapshotDirty = true;
369
+ }
370
+
371
+ // ---------------------------------------------------------------------------
372
+ // Observation boundary
373
+ // ---------------------------------------------------------------------------
374
+
375
+ /**
376
+ * The one inspection boundary every capture passes through. An event-scope
377
+ * failure disables further event capture after saturating the drop counter;
378
+ * a topology-scope failure drops that entry and marks the snapshot
379
+ * truncated. Neither can propagate: an inspection failure must not change a
380
+ * response, a lifecycle result, or a startup error.
381
+ *
382
+ * @param scope - Which capture family the callback belongs to
383
+ * @param emit - The capture work
384
+ * @returns The callback's value, or `undefined` when it failed
385
+ */
386
+ safeObserve<T>(scope: 'event' | 'topology', emit: () => T): T | undefined {
387
+ try {
388
+ return emit();
389
+ } catch {
390
+ if (scope === 'event') {
391
+ this.#eventCaptureDisabled = true;
392
+ this.#noteDroppedEvent();
393
+ } else {
394
+ this.#topologyTruncated = true;
395
+ this.#snapshotDirty = true;
396
+ }
397
+ return undefined;
398
+ }
399
+ }
400
+
401
+ /** Guarded monotonic offset in ms from runtime initialization, or `null`. */
402
+ monotonicMs(): number | null {
403
+ const clock = this.#clock;
404
+ return clock === undefined ? null : clock();
405
+ }
406
+
407
+ // ---------------------------------------------------------------------------
408
+ // Topology capture
409
+ // ---------------------------------------------------------------------------
410
+
411
+ /**
412
+ * Captures one plugin at its registration boundary: the node plus its
413
+ * DECLARED dependency edges. Names are projected through the allowlists
414
+ * here, at capture — the collector never stores a label that was not
415
+ * explicitly approved.
416
+ *
417
+ * @param info - The declared plugin description
418
+ * @returns The plugin node id, or `undefined` when the capture was dropped
419
+ */
420
+ pluginRegistered(info: PluginRegistrationInfo): string | undefined {
421
+ return this.safeObserve('topology', () => {
422
+ const id = this.#nextNodeId('p', this.#pluginCounter, (next) => {
423
+ this.#pluginCounter = next;
424
+ });
425
+ if (id === undefined) {
426
+ return undefined;
427
+ }
428
+ const label = approvedLabel(this.#labels.plugins, info.name);
429
+ const version = boundedPluginVersion(info.version);
430
+ const node: NodeRecord = {
431
+ id,
432
+ kind: 'plugin',
433
+ ...(label !== undefined ? { label } : {}),
434
+ ...(version !== undefined ? { version } : {}),
435
+ };
436
+ this.#nodes.push(node);
437
+ this.#pluginNodesByName.set(info.name, node);
438
+ this.#snapshotDirty = true;
439
+ this.#declareEdges(id, 'provides', info.provides);
440
+ this.#declareEdges(id, 'requires', info.requires);
441
+ this.#declareEdges(id, 'optional', info.optionalDependencies);
442
+ this.#declareEdges(id, 'consumes', info.consumes);
443
+ return id;
444
+ });
445
+ }
446
+
447
+ /**
448
+ * Observes a successful registry registration (instance, factory, or
449
+ * multi-provider) or removal, using token metadata alone. The registry's
450
+ * existing logging observer is untouched.
451
+ *
452
+ * @param event - The registry's registration event
453
+ * @param owner - The plugin whose `register()` was running, when known
454
+ */
455
+ capabilityRegistrationObserved(event: RegistryDiagnosticEvent, owner: string | undefined): void {
456
+ this.safeObserve('topology', () => {
457
+ const node = this.#capabilityNodeFor(event.token);
458
+ if (event.kind === 'unregister') {
459
+ node.registered = false;
460
+ } else {
461
+ node.registered = true;
462
+ const ownerNode = owner === undefined ? undefined : this.#pluginNodesByName.get(owner);
463
+ if (ownerNode !== undefined) {
464
+ this.#addEdge(ownerNode.id, node.id, 'owns');
465
+ }
466
+ }
467
+ this.#snapshotDirty = true;
468
+ });
469
+ }
470
+
471
+ /**
472
+ * Captures one route registration with its route-middleware nodes.
473
+ *
474
+ * @param event - The router's registration event
475
+ * @returns The route node id, or `undefined` when the capture was dropped
476
+ */
477
+ routeRegistered(event: RouteRegistrationEvent): string | undefined {
478
+ return this.safeObserve('topology', () => {
479
+ const id = this.#nextNodeId('r', this.#routeCounter, (next) => {
480
+ this.#routeCounter = next;
481
+ });
482
+ if (id === undefined) {
483
+ return undefined;
484
+ }
485
+ const label = approvedLabel(this.#labels.routes, event.pattern);
486
+ const method = projectHttpMethod(event.method);
487
+ const node: NodeRecord = {
488
+ id,
489
+ kind: 'route',
490
+ ...(label !== undefined ? { label } : {}),
491
+ ...(method !== undefined ? { method } : {}),
492
+ };
493
+ this.#nodes.push(node);
494
+ this.#routeNodeIds.set(event.entryIndex, node);
495
+ this.#snapshotDirty = true;
496
+ // Route middleware without an explicit approved name gets only its id
497
+ // and position; function names are never consulted.
498
+ const stages: NodeRecord[] = [];
499
+ for (let position = 1; position <= event.middlewareCount; position++) {
500
+ const stageId = this.#nextNodeId('m', this.#middlewareCounter, (next) => {
501
+ this.#middlewareCounter = next;
502
+ });
503
+ if (stageId === undefined) {
504
+ break;
505
+ }
506
+ const stage: NodeRecord = { id: stageId, kind: 'middleware', position };
507
+ this.#nodes.push(stage);
508
+ stages.push(stage);
509
+ }
510
+ this.#routeMiddlewareNodes.set(event.entryIndex, stages);
511
+ const ownerNode =
512
+ event.owner === undefined ? undefined : this.#pluginNodesByName.get(event.owner);
513
+ if (ownerNode !== undefined) {
514
+ this.#addEdge(ownerNode.id, id, 'owns');
515
+ }
516
+ return id;
517
+ });
518
+ }
519
+
520
+ /**
521
+ * Captures the compiled global pipeline in execution order — the stable
522
+ * priority sort — so middleware stage records can name their node.
523
+ *
524
+ * @param descriptors - The compiled stages, in execution order
525
+ */
526
+ middlewareCompiled(descriptors: readonly MiddlewareCompiledDescriptor[]): void {
527
+ this.safeObserve('topology', () => {
528
+ const nodes: NodeRecord[] = [];
529
+ for (const descriptor of descriptors) {
530
+ const id = this.#nextNodeId('m', this.#middlewareCounter, (next) => {
531
+ this.#middlewareCounter = next;
532
+ });
533
+ if (id === undefined) {
534
+ break;
535
+ }
536
+ const label = approvedLabel(this.#labels.middleware, descriptor.name);
537
+ // A priority is the application's own number, recorded as given —
538
+ // except a non-finite one (`Number(env.X)` of an unset variable is
539
+ // NaN), which is omitted: JSON has no NaN/Infinity, so it would reach
540
+ // the wire as `null` in a field the DTO types as `number`.
541
+ const priority = Number.isFinite(descriptor.priority) ? descriptor.priority : undefined;
542
+ const node: NodeRecord = {
543
+ id,
544
+ kind: 'middleware',
545
+ ...(label !== undefined ? { label } : {}),
546
+ ...(priority !== undefined ? { priority } : {}),
547
+ position: descriptor.position,
548
+ };
549
+ this.#nodes.push(node);
550
+ nodes.push(node);
551
+ }
552
+ this.#globalMiddlewareNodes.push(...nodes);
553
+ this.#snapshotDirty = true;
554
+ });
555
+ }
556
+
557
+ /** Route node id for a router entry index, when that route was captured. */
558
+ routeNodeIdOf(entryIndex: number): string | undefined {
559
+ return this.#routeNodeIds.get(entryIndex)?.id;
560
+ }
561
+
562
+ // ---------------------------------------------------------------------------
563
+ // Execution capture
564
+ // ---------------------------------------------------------------------------
565
+
566
+ /**
567
+ * Allocates an operation root at entry. The identifier exists before any
568
+ * record is written, so a child may reference a parent whose completion
569
+ * record appears later.
570
+ *
571
+ * @returns The operation handle
572
+ */
573
+ beginOperation(): DiagnosticsOperation {
574
+ return {
575
+ id: this.#nextOperationId(),
576
+ startedAtMs: this.monotonicMs(),
577
+ };
578
+ }
579
+
580
+ /**
581
+ * Allocates the request operation for a context, stored in a private
582
+ * WeakMap — never in application state, never a caller-supplied id.
583
+ *
584
+ * @param ctx - The request context keying the operation
585
+ */
586
+ beginRequestOperation(ctx: object): void {
587
+ this.#requestOperations.set(ctx, this.beginOperation());
588
+ }
589
+
590
+ /**
591
+ * Emits the request operation's completion record.
592
+ *
593
+ * @param ctx - The request context keying the operation
594
+ * @param parts - Outcome and the produced status code
595
+ */
596
+ endRequestOperation(
597
+ ctx: object,
598
+ parts: { outcome: DiagnosticsEventOutcome; statusCode?: number },
599
+ ): void {
600
+ const operation = this.#requestOperations.get(ctx);
601
+ if (operation === undefined) {
602
+ return;
603
+ }
604
+ this.#requestOperations.delete(ctx);
605
+ this.#emitEvent({
606
+ kind: 'request',
607
+ stage: 'request',
608
+ operationId: operation.id,
609
+ parentOperationId: null,
610
+ nodeId: null,
611
+ outcome: parts.outcome,
612
+ startedAtMs: operation.startedAtMs,
613
+ durationMs: this.#durationSince(operation.startedAtMs),
614
+ ...(parts.statusCode !== undefined ? { statusCode: parts.statusCode } : {}),
615
+ withTrace: true,
616
+ });
617
+ }
618
+
619
+ /**
620
+ * Emits one middleware stage completion record (global or route chain).
621
+ *
622
+ * @param parts - The stage observation from the chain executor
623
+ */
624
+ observeMiddlewareStage(parts: {
625
+ parentOperationId: string;
626
+ scope: 'global' | 'route';
627
+ position: number;
628
+ routeEntryIndex: number | undefined;
629
+ outcome: DiagnosticsEventOutcome;
630
+ startedAtMs: number | null;
631
+ durationMs: number | null;
632
+ }): void {
633
+ const node =
634
+ parts.scope === 'global'
635
+ ? this.#globalMiddlewareNodes[parts.position]?.id
636
+ : this.#routeMiddlewareNodes.get(parts.routeEntryIndex ?? -1)?.[parts.position]?.id;
637
+ this.#emitEvent({
638
+ kind: 'middleware',
639
+ stage: parts.scope,
640
+ operationId: this.#nextOperationId(),
641
+ parentOperationId: parts.parentOperationId,
642
+ nodeId: node ?? null,
643
+ outcome: parts.outcome,
644
+ startedAtMs: parts.startedAtMs,
645
+ durationMs: parts.durationMs,
646
+ });
647
+ }
648
+
649
+ /**
650
+ * Emits one handler or protocol-boundary completion record.
651
+ *
652
+ * @param parts - The handler observation
653
+ */
654
+ observeHandlerStage(parts: {
655
+ parentOperationId: string;
656
+ stage: 'handler' | 'websocket-upgrade' | 'grpc-dispatch';
657
+ nodeId: string | null;
658
+ outcome: DiagnosticsEventOutcome;
659
+ startedAtMs: number | null;
660
+ durationMs: number | null;
661
+ statusCode?: number;
662
+ }): void {
663
+ this.#emitEvent({
664
+ kind: 'handler',
665
+ stage: parts.stage,
666
+ operationId: this.#nextOperationId(),
667
+ parentOperationId: parts.parentOperationId,
668
+ nodeId: parts.nodeId,
669
+ outcome: parts.outcome,
670
+ startedAtMs: parts.startedAtMs,
671
+ durationMs: parts.durationMs,
672
+ ...(parts.statusCode !== undefined ? { statusCode: parts.statusCode } : {}),
673
+ withTrace: true,
674
+ });
675
+ }
676
+
677
+ /**
678
+ * Emits one request-scoped hook completion record (`request-hook`,
679
+ * `response-hook`, `error-hook`).
680
+ *
681
+ * @param parts - The hook observation
682
+ */
683
+ observeRequestStage(parts: {
684
+ parentOperationId: string;
685
+ stage: 'request-hook' | 'response-hook' | 'error-hook';
686
+ outcome: DiagnosticsEventOutcome;
687
+ startedAtMs: number | null;
688
+ durationMs: number | null;
689
+ }): void {
690
+ this.#emitEvent({
691
+ kind: 'request',
692
+ stage: parts.stage,
693
+ operationId: this.#nextOperationId(),
694
+ parentOperationId: parts.parentOperationId,
695
+ nodeId: null,
696
+ outcome: parts.outcome,
697
+ startedAtMs: parts.startedAtMs,
698
+ durationMs: parts.durationMs,
699
+ });
700
+ }
701
+
702
+ /**
703
+ * Emits one lifecycle completion record for an operation allocated at entry
704
+ * with {@linkcode beginOperation}.
705
+ *
706
+ * @param operation - The operation allocated at the boundary's entry
707
+ * @param stage - The lifecycle boundary that completed
708
+ * @param nodeId - The plugin node the boundary belongs to, when known
709
+ * @param outcome - How the boundary completed
710
+ */
711
+ observeLifecycleEvent(
712
+ operation: DiagnosticsOperation,
713
+ stage: DiagnosticsEventStage,
714
+ nodeId: string | null,
715
+ outcome: DiagnosticsEventOutcome,
716
+ ): void {
717
+ this.#emitEvent({
718
+ kind: 'lifecycle',
719
+ stage,
720
+ operationId: operation.id,
721
+ parentOperationId: null,
722
+ nodeId,
723
+ outcome,
724
+ startedAtMs: operation.startedAtMs,
725
+ durationMs: this.elapsedSince(operation.startedAtMs),
726
+ });
727
+ }
728
+
729
+ /**
730
+ * Emits one per-hook lifecycle record observed by the lifecycle manager —
731
+ * fixed phase plus execution ordinal, never an inferred owner.
732
+ *
733
+ * @param observation - The manager's hook observation
734
+ */
735
+ observeLifecycleHook(observation: {
736
+ phase: 'register-hook' | 'init' | 'bootstrap' | 'stopping' | 'shutdown' | 'close';
737
+ ordinal: number;
738
+ failed: boolean;
739
+ startedAtMs: number | null;
740
+ durationMs: number | null;
741
+ }): void {
742
+ this.#emitEvent({
743
+ kind: 'lifecycle',
744
+ stage: observation.phase,
745
+ operationId: this.#nextOperationId(),
746
+ parentOperationId: null,
747
+ nodeId: null,
748
+ outcome: observation.failed ? 'error' : 'ok',
749
+ startedAtMs: observation.startedAtMs,
750
+ durationMs: observation.durationMs,
751
+ });
752
+ }
753
+
754
+ /**
755
+ * The request operation identifier keyed to a context, when one exists.
756
+ *
757
+ * @param ctx - The request context the operation was keyed under
758
+ * @returns The operation id, or `undefined`
759
+ */
760
+ requestOperationIdOf(ctx: object): string | undefined {
761
+ return this.#requestOperations.get(ctx)?.id;
762
+ }
763
+
764
+ /**
765
+ * Inclusive monotonic elapsed time since a recorded start offset.
766
+ *
767
+ * @param startedAtMs - The recorded start offset, or `null`
768
+ * @returns Elapsed ms, or `null` when either reading was unavailable
769
+ */
770
+ elapsedSince(startedAtMs: number | null): number | null {
771
+ return this.#durationSince(startedAtMs);
772
+ }
773
+
774
+ // ---------------------------------------------------------------------------
775
+ // Internals
776
+ // ---------------------------------------------------------------------------
777
+
778
+ #transition(state: DiagnosticsSnapshotState): void {
779
+ if (this.#state === 'failed' || this.#state === 'closed') {
780
+ return;
781
+ }
782
+ this.#state = state;
783
+ this.#snapshotDirty = true;
784
+ }
785
+
786
+ #clearRetained(): void {
787
+ this.#nodes.length = 0;
788
+ this.#edges.length = 0;
789
+ this.#edgeKeys.clear();
790
+ this.#pluginNodesByName.clear();
791
+ this.#capabilityNodesByToken.clear();
792
+ this.#routeNodeIds.clear();
793
+ this.#routeMiddlewareNodes.clear();
794
+ this.#globalMiddlewareNodes.length = 0;
795
+ this.#ring.clear();
796
+ this.#cachedSnapshot = undefined;
797
+ this.#snapshotDirty = true;
798
+ }
799
+
800
+ #nextNodeId(prefix: string, current: number, assign: (next: number) => void): string | undefined {
801
+ // Fixed v1 limit: stop ADDING topology at 1,024 nodes and report the
802
+ // truncation rather than retaining unbounded metadata.
803
+ if (this.#nodes.length >= MAX_NODES) {
804
+ this.#topologyTruncated = true;
805
+ this.#snapshotDirty = true;
806
+ return undefined;
807
+ }
808
+ const next = saturatingNext(current);
809
+ if (next === null) {
810
+ this.#topologyTruncated = true;
811
+ this.#snapshotDirty = true;
812
+ return undefined;
813
+ }
814
+ assign(next);
815
+ return `${prefix}${next}`;
816
+ }
817
+
818
+ #nextOperationId(): string {
819
+ // Saturation here is unreachable within any real run; the guard exists so
820
+ // a hostile synthetic workload stops event capture instead of minting a
821
+ // wrapped identifier.
822
+ const next = saturatingNext(this.#opCounter);
823
+ if (next === null) {
824
+ this.#eventCaptureDisabled = true;
825
+ return 'op0';
826
+ }
827
+ this.#opCounter = next;
828
+ return `op${next}`;
829
+ }
830
+
831
+ #capabilityNodeFor(token: string): NodeRecord {
832
+ const existing = this.#capabilityNodesByToken.get(token);
833
+ if (existing !== undefined) {
834
+ return existing;
835
+ }
836
+ const id = this.#nextNodeId('c', this.#capabilityCounter, (next) => {
837
+ this.#capabilityCounter = next;
838
+ });
839
+ if (id === undefined) {
840
+ // Node cap reached: return a detached record so the caller's mutation
841
+ // stays value-free; edges to it are omitted because its id is unknown.
842
+ return { id: '', kind: 'capability' };
843
+ }
844
+ const label = approvedLabel(this.#labels.capabilities, token);
845
+ const node: NodeRecord = {
846
+ id,
847
+ kind: 'capability',
848
+ ...(label !== undefined ? { label } : {}),
849
+ registered: false,
850
+ };
851
+ this.#nodes.push(node);
852
+ this.#capabilityNodesByToken.set(token, node);
853
+ this.#snapshotDirty = true;
854
+ return node;
855
+ }
856
+
857
+ #declareEdges(pluginId: string, kind: DiagnosticsEdgeKind, tokens: readonly string[]): void {
858
+ for (const token of tokens) {
859
+ const capability = this.#capabilityNodeFor(token);
860
+ if (capability.id !== '') {
861
+ this.#addEdge(pluginId, capability.id, kind);
862
+ }
863
+ }
864
+ }
865
+
866
+ #addEdge(from: string, to: string, kind: DiagnosticsEdgeKind): void {
867
+ // Edges whose endpoints were dropped (node cap) are omitted, never dangling.
868
+ if (from === '' || to === '') {
869
+ return;
870
+ }
871
+ const key = `${from}|${to}|${kind}`;
872
+ if (this.#edgeKeys.has(key)) {
873
+ return;
874
+ }
875
+ if (this.#edges.length >= MAX_EDGES) {
876
+ this.#topologyTruncated = true;
877
+ this.#snapshotDirty = true;
878
+ return;
879
+ }
880
+ this.#edgeKeys.add(key);
881
+ this.#edges.push({ from, to, kind });
882
+ this.#snapshotDirty = true;
883
+ }
884
+
885
+ #noteDroppedEvent(): void {
886
+ const next = saturatingNext(this.#droppedEvents);
887
+ this.#droppedEvents = next ?? Number.MAX_SAFE_INTEGER;
888
+ this.#snapshotDirty = true;
889
+ }
890
+
891
+ #durationSince(startedAtMs: number | null): number | null {
892
+ if (startedAtMs === null) {
893
+ return null;
894
+ }
895
+ const finishedAtMs = this.monotonicMs();
896
+ return finishedAtMs === null ? null : finishedAtMs - startedAtMs;
897
+ }
898
+
899
+ #traceIdentifiers(): TraceIdentifiers {
900
+ const reader = this.#telemetryReader;
901
+ if (reader === undefined) {
902
+ return {};
903
+ }
904
+ let service: TraceReadingTelemetry | undefined;
905
+ try {
906
+ service = reader();
907
+ } catch {
908
+ return {};
909
+ }
910
+ const activeSpanContext = service?.activeSpanContext;
911
+ if (activeSpanContext === undefined) {
912
+ return {};
913
+ }
914
+ let context: { readonly traceId: string; readonly spanId: string } | undefined;
915
+ try {
916
+ context = activeSpanContext.call(service);
917
+ } catch {
918
+ return {};
919
+ }
920
+ if (context === undefined) {
921
+ return {};
922
+ }
923
+ // Identifiers are carried only when they are well-formed and non-zero —
924
+ // the W3C all-zero ids mean "invalid", not "anonymous".
925
+ const traceId =
926
+ TRACE_ID_PATTERN.test(context.traceId) && NON_ZERO.test(context.traceId)
927
+ ? context.traceId
928
+ : undefined;
929
+ const spanId =
930
+ SPAN_ID_PATTERN.test(context.spanId) && NON_ZERO.test(context.spanId)
931
+ ? context.spanId
932
+ : undefined;
933
+ return {
934
+ ...(traceId !== undefined ? { traceId } : {}),
935
+ ...(spanId !== undefined ? { spanId } : {}),
936
+ };
937
+ }
938
+
939
+ /**
940
+ * The terminal observation boundary: the one place an event is built and
941
+ * stored, so an event-capture failure disables capture exactly once and
942
+ * never propagates into a response, a lifecycle result, or a startup error.
943
+ *
944
+ * @param parts - The completion record to emit
945
+ */
946
+ #emitEvent(parts: CompletionRecordParts): void {
947
+ if (this.#eventCaptureDisabled || this.#ring.closed) {
948
+ return;
949
+ }
950
+ try {
951
+ this.#buildAndStoreEvent(parts);
952
+ } catch {
953
+ this.#eventCaptureDisabled = true;
954
+ this.#noteDroppedEvent();
955
+ }
956
+ }
957
+
958
+ #buildAndStoreEvent(parts: CompletionRecordParts): void {
959
+ const identifiers = parts.withTrace === true ? this.#traceIdentifiers() : {};
960
+ // The byte cap is enforced BEFORE serialization (§3.6): every field is a
961
+ // collector-owned bounded string, so the structural bound decides, and
962
+ // an oversized event is dropped WHOLE without ever building it.
963
+ const traceId = identifiers.traceId;
964
+ const spanId = identifiers.spanId;
965
+ // A status is recorded as the application set it — any finite number,
966
+ // ranged or not — but a non-finite one is omitted, for the same reason a
967
+ // non-finite priority is: it would serialize to `null` in a `number` field.
968
+ const statusCode =
969
+ parts.statusCode !== undefined && Number.isFinite(parts.statusCode)
970
+ ? parts.statusCode
971
+ : undefined;
972
+ const parentLength = parts.parentOperationId?.length ?? 0;
973
+ const withinCap = eventWithinByteCap({
974
+ operationId: parts.operationId.length,
975
+ parentOperationId: parentLength,
976
+ nodeId: parts.nodeId?.length ?? 0,
977
+ traceId: traceId?.length ?? 0,
978
+ spanId: spanId?.length ?? 0,
979
+ statusCode: statusCode !== undefined,
980
+ });
981
+ if (
982
+ parts.operationId.length > MAX_EVENT_FIELD_LENGTH ||
983
+ parentLength > MAX_EVENT_FIELD_LENGTH ||
984
+ (parts.nodeId?.length ?? 0) > MAX_EVENT_FIELD_LENGTH ||
985
+ !withinCap
986
+ ) {
987
+ this.#noteDroppedEvent();
988
+ return;
989
+ }
990
+ const sequence = this.#ring.allocateSequence();
991
+ if (sequence === null) {
992
+ this.#eventCaptureDisabled = true;
993
+ this.#noteDroppedEvent();
994
+ return;
995
+ }
996
+ // Fields are primitives only, so a shallow freeze is the deep freeze.
997
+ const event: DiagnosticsEvent = Object.freeze({
998
+ sequence,
999
+ operationId: parts.operationId,
1000
+ parentOperationId: parts.parentOperationId,
1001
+ kind: parts.kind,
1002
+ stage: parts.stage,
1003
+ nodeId: parts.nodeId,
1004
+ outcome: parts.outcome,
1005
+ atMs: parts.startedAtMs,
1006
+ durationMs: parts.durationMs,
1007
+ ...(statusCode !== undefined ? { statusCode } : {}),
1008
+ ...(traceId !== undefined ? { traceId } : {}),
1009
+ ...(spanId !== undefined ? { spanId } : {}),
1010
+ });
1011
+ this.#ring.store(sequence, event);
1012
+ }
1013
+ }
1014
+
1015
+ function nodeToDto(node: NodeRecord): DiagnosticsNode {
1016
+ return {
1017
+ id: node.id,
1018
+ kind: node.kind,
1019
+ ...(node.label !== undefined ? { label: node.label } : {}),
1020
+ ...(node.version !== undefined ? { version: node.version } : {}),
1021
+ ...(node.method !== undefined ? { method: node.method } : {}),
1022
+ ...(node.priority !== undefined ? { priority: node.priority } : {}),
1023
+ ...(node.position !== undefined ? { position: node.position } : {}),
1024
+ ...(node.registered !== undefined ? { registered: node.registered } : {}),
1025
+ };
1026
+ }
1027
+
1028
+ function edgeToDto(edge: EdgeRecord): DiagnosticsEdge {
1029
+ return { from: edge.from, to: edge.to, kind: edge.kind };
1030
+ }