@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.
- package/AGENT_CARD.md +47 -0
- package/LICENSE +21 -0
- package/README.md +152 -2
- package/dist/application/application.d.ts +174 -0
- package/dist/application/application.d.ts.map +1 -0
- package/dist/application/application.js +1905 -0
- package/dist/application/application.js.map +1 -0
- package/dist/application/inject-body.d.ts +58 -0
- package/dist/application/inject-body.d.ts.map +1 -0
- package/dist/application/inject-body.js +82 -0
- package/dist/application/inject-body.js.map +1 -0
- package/dist/context/request-context.d.ts +32 -0
- package/dist/context/request-context.d.ts.map +1 -0
- package/dist/context/request-context.js +136 -0
- package/dist/context/request-context.js.map +1 -0
- package/dist/context/response.d.ts +60 -0
- package/dist/context/response.d.ts.map +1 -0
- package/dist/context/response.js +193 -0
- package/dist/context/response.js.map +1 -0
- package/dist/diagnostics/buffer.d.ts +143 -0
- package/dist/diagnostics/buffer.d.ts.map +1 -0
- package/dist/diagnostics/buffer.js +190 -0
- package/dist/diagnostics/buffer.js.map +1 -0
- package/dist/diagnostics/collector.d.ts +284 -0
- package/dist/diagnostics/collector.d.ts.map +1 -0
- package/dist/diagnostics/collector.js +810 -0
- package/dist/diagnostics/collector.js.map +1 -0
- package/dist/diagnostics/projection.d.ts +153 -0
- package/dist/diagnostics/projection.d.ts.map +1 -0
- package/dist/diagnostics/projection.js +226 -0
- package/dist/diagnostics/projection.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle/lifecycle-manager.d.ts +136 -0
- package/dist/lifecycle/lifecycle-manager.d.ts.map +1 -0
- package/dist/lifecycle/lifecycle-manager.js +206 -0
- package/dist/lifecycle/lifecycle-manager.js.map +1 -0
- package/dist/pipeline/execute-chain.d.ts +71 -0
- package/dist/pipeline/execute-chain.d.ts.map +1 -0
- package/dist/pipeline/execute-chain.js +104 -0
- package/dist/pipeline/execute-chain.js.map +1 -0
- package/dist/pipeline/middleware-pipeline.d.ts +68 -0
- package/dist/pipeline/middleware-pipeline.d.ts.map +1 -0
- package/dist/pipeline/middleware-pipeline.js +82 -0
- package/dist/pipeline/middleware-pipeline.js.map +1 -0
- package/dist/registry/plugin-resolver.d.ts +42 -0
- package/dist/registry/plugin-resolver.d.ts.map +1 -0
- package/dist/registry/plugin-resolver.js +140 -0
- package/dist/registry/plugin-resolver.js.map +1 -0
- package/dist/registry/service-registry.d.ts +110 -0
- package/dist/registry/service-registry.d.ts.map +1 -0
- package/dist/registry/service-registry.js +197 -0
- package/dist/registry/service-registry.js.map +1 -0
- package/dist/router/route-matcher.d.ts +85 -0
- package/dist/router/route-matcher.d.ts.map +1 -0
- package/dist/router/route-matcher.js +105 -0
- package/dist/router/route-matcher.js.map +1 -0
- package/dist/router/router.d.ts +130 -0
- package/dist/router/router.d.ts.map +1 -0
- package/dist/router/router.js +350 -0
- package/dist/router/router.js.map +1 -0
- package/package.json +49 -3
- package/src/application/application.ts +2326 -0
- package/src/application/inject-body.ts +130 -0
- package/src/context/request-context.ts +169 -0
- package/src/context/response.ts +246 -0
- package/src/diagnostics/buffer.ts +243 -0
- package/src/diagnostics/collector.ts +1030 -0
- package/src/diagnostics/projection.ts +322 -0
- package/src/index.ts +20 -0
- package/src/lifecycle/lifecycle-manager.ts +282 -0
- package/src/pipeline/execute-chain.ts +162 -0
- package/src/pipeline/middleware-pipeline.ts +129 -0
- package/src/registry/plugin-resolver.ts +185 -0
- package/src/registry/service-registry.ts +262 -0
- package/src/router/route-matcher.ts +134 -0
- 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
|
+
}
|