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