effect-inspect 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +68 -0
  2. package/app/dist/client/assets/{index-smV05cfr.js → index-T-bCzOWw.js} +2 -2
  3. package/app/dist/client/assets/routes-C2qO8k2W.js +5 -0
  4. package/app/dist/server/assets/{_tanstack-start-manifest_v-CFQ3DEVN.js → _tanstack-start-manifest_v-B2BMiICr.js} +3 -3
  5. package/app/dist/server/assets/{router-CN98Ramo.js → router-dMcw-pHq.js} +1 -1
  6. package/app/dist/server/assets/{routes-eZ4XqxE9.js → routes-BmnrEVoN.js} +203 -157
  7. package/app/dist/server/server.js +2 -2
  8. package/dist/cli/QueryCommands.d.ts +127 -0
  9. package/dist/cli/QueryCommands.js +823 -0
  10. package/dist/cli.d.ts +1 -2
  11. package/dist/cli.js +165 -8
  12. package/dist/client/Client.d.ts +42 -1
  13. package/dist/client/Client.js +88 -4
  14. package/dist/collector/QueryApi.d.ts +29 -0
  15. package/dist/collector/QueryApi.js +91 -0
  16. package/dist/collector/Server.d.ts +1 -1
  17. package/dist/collector/Server.js +15 -8
  18. package/dist/collector/Store.d.ts +56 -16
  19. package/dist/collector/Store.js +37 -11
  20. package/dist/protocol/Codec.d.ts +10 -0
  21. package/dist/protocol/Schema.d.ts +118 -1
  22. package/dist/protocol/Schema.js +53 -1
  23. package/dist/query/Client.d.ts +30 -0
  24. package/dist/query/Client.js +74 -0
  25. package/dist/query/Query.d.ts +689 -0
  26. package/dist/query/Query.js +1017 -0
  27. package/dist/trace/Timing.d.ts +18 -0
  28. package/dist/trace/Timing.js +57 -0
  29. package/dist/trace/TraceFile.d.ts +54 -0
  30. package/dist/trace/TraceFile.js +86 -0
  31. package/dist/trace/TraceStore.d.ts +202 -0
  32. package/dist/trace/TraceStore.js +328 -0
  33. package/package.json +3 -1
  34. package/app/dist/client/assets/routes-TKgeFdSW.js +0 -5
@@ -0,0 +1,1017 @@
1
+ /**
2
+ * Headless, read-only trace queries with one contract for every source.
3
+ *
4
+ * A {@link TraceSource} is a frozen session: a collector snapshot or a saved
5
+ * `.eitrace`. Both are the same protocol message stream, so both are analysed
6
+ * by the same code — the browser's {@link TraceStore} reconstruction and
7
+ * {@link timings} math — and a snapshot queried live answers exactly as its
8
+ * exported file does.
9
+ *
10
+ * Every answer is JSON, bounded in size, names its source and time reference,
11
+ * and says what it cannot vouch for: a response never presents partial or
12
+ * conflicting evidence as a complete, unambiguous run. Requests and responses
13
+ * are plain data so the CLI can print them and the collector can serve them.
14
+ *
15
+ * Timing is observation, not diagnosis. `durationMs` is elapsed time between a
16
+ * span's recorded start and end; `outsideChildrenMs` is the part of it not
17
+ * covered by any recorded direct child. Neither is CPU time, and neither says
18
+ * an operation is slow, stuck or under-instrumented on its own.
19
+ */
20
+ import { Result, Schema } from 'effect';
21
+ import { timings } from '../trace/Timing.js';
22
+ import { parseTraceFile, serializeTraceFile } from '../trace/TraceFile.js';
23
+ import { TraceStore } from '../trace/TraceStore.js';
24
+ /** Version of this request/response contract. Bumped on a breaking change. */
25
+ export const apiVersion = 1;
26
+ /** Defaults and caps for every bounded part of a response. */
27
+ export const limits = {
28
+ /** `sessions` page size. */
29
+ sessions: { default: 50, max: 500 },
30
+ /** `spans` page size. */
31
+ spans: { default: 20, max: 200 },
32
+ /** `logs` page size. */
33
+ logs: { default: 50, max: 500 },
34
+ /** Items per ranked list in `summary`. */
35
+ top: { default: 5, max: 50 },
36
+ /** Children listed by `span`. */
37
+ children: { default: 20, max: 200 },
38
+ /** Span events listed by `span`. */
39
+ events: { default: 20, max: 200 },
40
+ /** Nearest ancestors listed by `span`. */
41
+ ancestry: 32,
42
+ /** Attribute/annotation keys kept per object; the rest are counted, not sent. */
43
+ attributeKeys: 32,
44
+ /** Characters of one JSON-encoded attribute value before it is cut. */
45
+ valueChars: 500,
46
+ /** Characters of a failure message. */
47
+ errorChars: 500,
48
+ /** Characters of a failure stack. */
49
+ stackChars: 4000,
50
+ /** Characters of a log message. */
51
+ logMessageChars: 2000,
52
+ /** Characters of a span, event or group name, a log's span name, or a program/runtime. */
53
+ nameChars: 200,
54
+ /** Characters of one attribute or annotation key. */
55
+ keyChars: 100,
56
+ /** Characters of an error `message`. */
57
+ errorMessageChars: 1000,
58
+ /** Longest `sessionId`, `spanId` or `name` a request may carry. */
59
+ requestTextChars: 1024,
60
+ /**
61
+ * UTF-8 bytes of a whole serialized JSON response, envelope included. A
62
+ * response that would be larger is replaced by `ResponseTooLarge`.
63
+ * Identifiers are never shortened to fit. `.eitrace` export is a lossless
64
+ * artifact transfer, not a query response, and is not held to this.
65
+ */
66
+ responseBytes: 1_048_576,
67
+ };
68
+ /** A live source from a collector `Store` snapshot taken at `now`. */
69
+ export const fromSnapshot = (snapshot, now) => ({
70
+ kind: 'live',
71
+ session: snapshot.session,
72
+ messages: snapshot.messages,
73
+ capture: {
74
+ droppedMessages: snapshot.droppedMessages,
75
+ skippedLines: snapshot.skippedLines,
76
+ conflictDetection: snapshot.conflictDetection,
77
+ },
78
+ truncatedLines: 0,
79
+ snapshotAtEpochMillis: now,
80
+ });
81
+ /**
82
+ * Trace-file text for a source, with its loss counters in the header so a
83
+ * query against the file reports the same completeness as the snapshot.
84
+ */
85
+ export const toTraceFile = (source) => serializeTraceFile(source.session, source.messages, source.snapshotAtEpochMillis, source.capture);
86
+ /** Parses trace-file text into a file source, or the `TraceFileError` response. */
87
+ export const fromTraceFile = (text, file) => Result.match(parseTraceFile(text), {
88
+ onFailure: (error) => Result.fail(failure(null, 'TraceFileError', error.message, 'Check the path points at a saved .eitrace file.', {
89
+ file,
90
+ })),
91
+ onSuccess: ({ header, messages, truncatedLines }) => Result.succeed({
92
+ kind: 'file',
93
+ file,
94
+ session: header.session,
95
+ messages,
96
+ capture: header.capture,
97
+ truncatedLines,
98
+ snapshotAtEpochMillis: header.savedAtEpochMillis,
99
+ }),
100
+ });
101
+ // ---------------------------------------------------------------------------
102
+ // Requests
103
+ // ---------------------------------------------------------------------------
104
+ const pageSize = (max) => Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: max }));
105
+ const Millis = Schema.Finite;
106
+ const RequestText = Schema.String.check(Schema.isMaxLength(limits.requestTextChars));
107
+ const sessionId = Schema.optional(RequestText);
108
+ /** Span status values a span can have. */
109
+ export const spanStatuses = ['ok', 'error', 'defect', 'interrupted', 'open'];
110
+ /** `spans` status filter: a {@link SpanStatus}, `failed` (any failure) or `any`. */
111
+ export const StatusFilter = Schema.Literals(['any', 'failed', ...spanStatuses]);
112
+ export const SpanSort = Schema.Literals(['start', 'duration', 'outsideChildren']);
113
+ /** Log levels a `minLevel` filter accepts, least severe first. */
114
+ export const logLevels = ['Trace', 'Debug', 'Info', 'Warn', 'Error', 'Fatal'];
115
+ export const MinLevel = Schema.Literals(logLevels);
116
+ export const LogScope = Schema.Literals(['span', 'subtree']);
117
+ /** Lists sessions. Live: every session the collector holds. File: the file's one session. */
118
+ export const SessionsRequest = Schema.Struct({
119
+ op: Schema.Literal('sessions'),
120
+ limit: Schema.optional(pageSize(limits.sessions.max)),
121
+ offset: Schema.optional(Schema.Natural),
122
+ });
123
+ /** Counts, failures and ranked timings for one session. */
124
+ export const SummaryRequest = Schema.Struct({
125
+ op: Schema.Literal('summary'),
126
+ sessionId,
127
+ top: Schema.optional(pageSize(limits.top.max)),
128
+ });
129
+ /** A filtered, sorted page of spans. */
130
+ export const SpansRequest = Schema.Struct({
131
+ op: Schema.Literal('spans'),
132
+ sessionId,
133
+ status: Schema.optional(StatusFilter),
134
+ /** Case-insensitive substring of the span name. */
135
+ name: Schema.optional(RequestText),
136
+ /** Only spans whose duration (or, while open, elapsed lower bound) is at least this. */
137
+ minDurationMs: Schema.optional(Millis.check(Schema.isGreaterThanOrEqualTo(0))),
138
+ /** Only spans overlapping `[fromMs, toMs]`. Timings are not clipped to it. */
139
+ fromMs: Schema.optional(Millis),
140
+ toMs: Schema.optional(Millis),
141
+ sort: Schema.optional(SpanSort),
142
+ limit: Schema.optional(pageSize(limits.spans.max)),
143
+ offset: Schema.optional(Schema.Natural),
144
+ });
145
+ /** One span with bounded ancestry, children, events and attributes. */
146
+ export const SpanRequest = Schema.Struct({
147
+ op: Schema.Literal('span'),
148
+ sessionId,
149
+ spanId: RequestText,
150
+ children: Schema.optional(pageSize(limits.children.max)),
151
+ events: Schema.optional(pageSize(limits.events.max)),
152
+ });
153
+ /** A page of logs, optionally correlated with a span. */
154
+ export const LogsRequest = Schema.Struct({
155
+ op: Schema.Literal('logs'),
156
+ sessionId,
157
+ spanId: Schema.optional(RequestText),
158
+ /** Requires `spanId`: that span's own logs, or its whole subtree's (default). */
159
+ scope: Schema.optional(LogScope),
160
+ minLevel: Schema.optional(MinLevel),
161
+ fromMs: Schema.optional(Millis),
162
+ toMs: Schema.optional(Millis),
163
+ limit: Schema.optional(pageSize(limits.logs.max)),
164
+ offset: Schema.optional(Schema.Natural),
165
+ });
166
+ export const QueryRequest = Schema.Union([
167
+ SessionsRequest,
168
+ SummaryRequest,
169
+ SpansRequest,
170
+ SpanRequest,
171
+ LogsRequest,
172
+ ]);
173
+ const decodeQuery = Schema.decodeUnknownResult(QueryRequest, { onExcessProperty: 'error' });
174
+ /**
175
+ * Validates an untrusted request. Unknown keys are rejected rather than
176
+ * ignored, so a misspelt filter can never silently widen a result.
177
+ */
178
+ export const decodeRequest = (input) => {
179
+ const op = typeof input === 'object' &&
180
+ input !== null &&
181
+ typeof input.op === 'string'
182
+ ? input.op
183
+ : null;
184
+ const invalid = (message, hint) => Result.fail(failure(op, 'InvalidRequest', message, hint));
185
+ const decoded = decodeQuery(input);
186
+ if (Result.isFailure(decoded)) {
187
+ return invalid(decoded.failure.message, 'Fix the request; see the documented fields.');
188
+ }
189
+ const request = decoded.success;
190
+ if ('fromMs' in request &&
191
+ request.fromMs !== undefined &&
192
+ request.toMs !== undefined &&
193
+ request.fromMs > request.toMs) {
194
+ return invalid('fromMs must not be after toMs.', 'Swap or fix the range.');
195
+ }
196
+ if (request.op === 'logs' && request.scope !== undefined && request.spanId === undefined) {
197
+ return invalid('scope applies only with spanId; it was given without one.', 'Add spanId to correlate logs with a span, or drop scope to list all logs.');
198
+ }
199
+ return Result.succeed(request);
200
+ };
201
+ /** Operations a response may name; anything else is echoed as `null`. */
202
+ const knownOps = new Set([
203
+ 'sessions',
204
+ 'summary',
205
+ 'spans',
206
+ 'span',
207
+ 'logs',
208
+ 'export',
209
+ ]);
210
+ /**
211
+ * A failure response. `op` is echoed only when it is a known operation and
212
+ * `message` is cut to `errorMessageChars`, so untrusted input cannot inflate it.
213
+ */
214
+ export const failure = (op, tag, message, hint, details) => {
215
+ const { text, truncated } = cut(message, limits.errorMessageChars);
216
+ return {
217
+ ok: false,
218
+ apiVersion,
219
+ op: op !== null && knownOps.has(op) ? op : null,
220
+ error: {
221
+ ...details,
222
+ _tag: tag,
223
+ message: text,
224
+ ...(truncated ? { messageTruncated: true } : {}),
225
+ hint,
226
+ },
227
+ };
228
+ };
229
+ const encoder = new TextEncoder();
230
+ /**
231
+ * Enforces {@link limits.responseBytes} on a complete response. An oversized
232
+ * response, success or failure, becomes a small fixed-shape
233
+ * `ResponseTooLarge` failure that echoes no caller text.
234
+ */
235
+ export const limitResponse = (response) => {
236
+ const bytes = encoder.encode(JSON.stringify(response)).length;
237
+ if (bytes <= limits.responseBytes)
238
+ return response;
239
+ return failure(response.op, 'ResponseTooLarge', `The response would be ${bytes} bytes of JSON, over the ${limits.responseBytes}-byte limit.`, 'Request fewer items (limit, top, children, events) or narrow the filters (status, name, minDurationMs, fromMs/toMs, minLevel, spanId/scope). Identifiers are never shortened, so extremely long IDs can make even a small page too large; export the trace and inspect the file directly.', {
240
+ bytes,
241
+ limitBytes: limits.responseBytes,
242
+ originalOutcome: response.ok ? 'ok' : response.error._tag,
243
+ });
244
+ };
245
+ // ---------------------------------------------------------------------------
246
+ // Analysis
247
+ // ---------------------------------------------------------------------------
248
+ /** Microsecond resolution is plenty and keeps float noise out of the JSON. */
249
+ const ms = (value) => Math.round(value * 1000) / 1000;
250
+ /** Cuts to at most `max` UTF-16 units without splitting a surrogate pair. */
251
+ function cut(text, max) {
252
+ if (text.length <= max)
253
+ return { text, truncated: false };
254
+ const code = text.charCodeAt(max - 1);
255
+ const end = code >= 0xd800 && code <= 0xdbff ? max - 1 : max;
256
+ return { text: text.slice(0, end), truncated: true };
257
+ }
258
+ const bound = (input) => {
259
+ const keys = Object.keys(input);
260
+ return {
261
+ entries: keys.slice(0, limits.attributeKeys).map((key) => {
262
+ const name = cut(key, limits.keyChars);
263
+ const value = input[key];
264
+ const encoded = cut(JSON.stringify(value), limits.valueChars);
265
+ return {
266
+ key: name.text,
267
+ keyTruncated: name.truncated,
268
+ value: encoded.truncated ? encoded.text : value,
269
+ valueTruncated: encoded.truncated,
270
+ };
271
+ }),
272
+ omittedKeys: Math.max(keys.length - limits.attributeKeys, 0),
273
+ };
274
+ };
275
+ const statusOf = (span) => {
276
+ if (span.end === undefined)
277
+ return 'open';
278
+ if (span.outcome?._tag !== 'Failure')
279
+ return 'ok';
280
+ if (span.outcome.kind === 'Fail')
281
+ return 'error';
282
+ if (span.outcome.kind === 'Die')
283
+ return 'defect';
284
+ return 'interrupted';
285
+ };
286
+ const matchesStatus = (status, filter) => {
287
+ if (filter === 'any')
288
+ return true;
289
+ if (filter === 'failed')
290
+ return status === 'error' || status === 'defect' || status === 'interrupted';
291
+ return status === filter;
292
+ };
293
+ /** A session's program and runtime, cut to `nameChars`. */
294
+ const describe = (session) => {
295
+ const program = cut(session.program, limits.nameChars);
296
+ const runtime = cut(session.runtime, limits.nameChars);
297
+ return {
298
+ program: program.text,
299
+ programTruncated: program.truncated,
300
+ pid: session.pid,
301
+ runtime: runtime.text,
302
+ runtimeTruncated: runtime.truncated,
303
+ };
304
+ };
305
+ /** A source reconstructed once, plus the evidence gaps the renderer ignores. */
306
+ class Analysis {
307
+ source;
308
+ store = new TraceStore();
309
+ /** Latest retained time (0 with none): open spans are measured up to here. */
310
+ now;
311
+ observedFrom;
312
+ observedUntil;
313
+ externalParents = new Map();
314
+ logCounts = new Map();
315
+ missingStarts;
316
+ outOfOrder;
317
+ clientDropped;
318
+ items = new Map();
319
+ /** Open spans: time so far not covered by a recorded child, a lower bound. */
320
+ openOutside = new Map();
321
+ constructor(source) {
322
+ this.source = source;
323
+ const store = this.store;
324
+ // Anchor at the session clock, not the first retained message, so times
325
+ // are identical across snapshots, evictions and exported files.
326
+ store.origin = source.session.clock.startTime;
327
+ store.epochOrigin = source.session.clock.wallClockEpochMillis;
328
+ const started = new Set();
329
+ const early = new Set();
330
+ let clientDropped = 0;
331
+ for (const message of source.messages) {
332
+ if (message._tag === 'SpanStart') {
333
+ started.add(message.spanId);
334
+ if (message.parent?._tag === 'ExternalParent') {
335
+ this.externalParents.set(message.spanId, message.parent);
336
+ }
337
+ }
338
+ else if ((message._tag === 'SpanEnd' || message._tag === 'SpanEvent') &&
339
+ !started.has(message.spanId)) {
340
+ early.add(message.spanId);
341
+ }
342
+ else if (message._tag === 'Log') {
343
+ const dropped = message.annotations['effect_inspect.dropped'];
344
+ if (typeof dropped === 'number')
345
+ clientDropped += dropped;
346
+ }
347
+ }
348
+ let outOfOrder = 0;
349
+ for (const id of early)
350
+ if (started.has(id))
351
+ outOfOrder++;
352
+ this.missingStarts = early.size - outOfOrder;
353
+ this.outOfOrder = outOfOrder;
354
+ this.clientDropped = clientDropped;
355
+ store.applyAll(source.messages);
356
+ // Computed here rather than read from the store's `duration`, which is
357
+ // floored at 0 for the renderer; times before the clock anchor are real.
358
+ let from = Number.POSITIVE_INFINITY;
359
+ let until = Number.NEGATIVE_INFINITY;
360
+ const observe = (time) => {
361
+ if (time < from)
362
+ from = time;
363
+ if (time > until)
364
+ until = time;
365
+ };
366
+ for (const span of store.spans.values()) {
367
+ observe(span.start);
368
+ if (span.end !== undefined)
369
+ observe(span.end);
370
+ for (const event of span.events)
371
+ observe(event.time);
372
+ }
373
+ for (const log of store.logs) {
374
+ observe(log.time);
375
+ if (log.spanId !== undefined) {
376
+ this.logCounts.set(log.spanId, (this.logCounts.get(log.spanId) ?? 0) + 1);
377
+ }
378
+ }
379
+ for (const sample of store.memory)
380
+ observe(sample.time);
381
+ this.observedFrom = from === Number.POSITIVE_INFINITY ? undefined : from;
382
+ this.observedUntil = until === Number.NEGATIVE_INFINITY ? undefined : until;
383
+ this.now = this.observedUntil ?? 0;
384
+ }
385
+ item(span) {
386
+ const cached = this.items.get(span.spanId);
387
+ if (cached !== undefined)
388
+ return cached;
389
+ const status = statusOf(span);
390
+ let openChildCount = 0;
391
+ for (const id of span.children) {
392
+ if (this.store.spans.get(id)?.end === undefined)
393
+ openChildCount++;
394
+ }
395
+ const closed = span.end !== undefined;
396
+ const { total, self } = timings(this.store, span, this.now);
397
+ if (!closed)
398
+ this.openOutside.set(span.spanId, ms(self));
399
+ const error = span.outcome?._tag === 'Failure'
400
+ ? (() => {
401
+ const { text, truncated } = cut(span.outcome.error, limits.errorChars);
402
+ return { kind: span.outcome.kind, message: text, messageTruncated: truncated };
403
+ })()
404
+ : null;
405
+ const name = cut(span.name, limits.nameChars);
406
+ const item = {
407
+ spanId: span.spanId,
408
+ traceId: span.traceId,
409
+ name: name.text,
410
+ nameTruncated: name.truncated,
411
+ kind: span.kind,
412
+ parentSpanId: span.parentId ?? null,
413
+ status,
414
+ startMs: ms(span.start),
415
+ endMs: closed ? ms(span.end) : null,
416
+ durationMs: closed ? ms(total) : null,
417
+ childCoveredMs: closed ? ms(total - self) : null,
418
+ outsideChildrenMs: closed ? ms(self) : null,
419
+ elapsedLowerBoundMs: closed ? null : ms(this.now - span.start),
420
+ childCount: span.children.length,
421
+ openChildCount,
422
+ eventCount: span.events.length,
423
+ logCount: this.logCounts.get(span.spanId) ?? 0,
424
+ error,
425
+ };
426
+ this.items.set(span.spanId, item);
427
+ return item;
428
+ }
429
+ context() {
430
+ const { source, store } = this;
431
+ const { session, capture } = source;
432
+ const counters = {
433
+ collectorDroppedMessages: capture?.droppedMessages ?? null,
434
+ collectorSkippedLines: capture?.skippedLines ?? null,
435
+ clientDroppedMessages: this.clientDropped,
436
+ fileTruncatedLines: source.truncatedLines,
437
+ spansMissingStart: this.missingStarts,
438
+ spansOutOfOrder: this.outOfOrder,
439
+ spansMissingParent: [...store.spans.values()].filter((span) => span.orphaned).length,
440
+ };
441
+ const lost = Object.values(counters).some((value) => value !== null && value > 0);
442
+ let status = 'noLossRecorded';
443
+ if (lost)
444
+ status = 'lossRecorded';
445
+ else if (capture === undefined)
446
+ status = 'unknown';
447
+ let detection = 'unknown';
448
+ if (capture !== undefined)
449
+ detection = capture.conflictDetection ? 'enforced' : 'unavailable';
450
+ const lastObservedMs = this.observedUntil === undefined ? null : ms(this.observedUntil);
451
+ const endedAtMs = session.endedAtEpochMillis === undefined
452
+ ? null
453
+ : ms(session.endedAtEpochMillis - session.clock.wallClockEpochMillis);
454
+ let state = 'unknown';
455
+ if (session.active)
456
+ state = 'active';
457
+ else if (endedAtMs !== null)
458
+ state = 'ended';
459
+ return {
460
+ source: {
461
+ kind: source.kind,
462
+ file: source.file ?? null,
463
+ sessionId: session.sessionId,
464
+ ...describe(session),
465
+ active: session.active,
466
+ startedAtEpochMillis: session.clock.wallClockEpochMillis,
467
+ endedAtEpochMillis: session.endedAtEpochMillis ?? null,
468
+ snapshotAtEpochMillis: source.snapshotAtEpochMillis,
469
+ },
470
+ time: {
471
+ unit: 'ms',
472
+ reference: 'sessionStart',
473
+ observedFromMs: this.observedFrom === undefined ? null : ms(this.observedFrom),
474
+ observedUntilMs: lastObservedMs,
475
+ },
476
+ termination: {
477
+ state,
478
+ lastObservedMs,
479
+ endedAtMs,
480
+ unobservedTailMs: endedAtMs === null || lastObservedMs === null ? null : ms(endedAtMs - lastObservedMs),
481
+ },
482
+ completeness: {
483
+ status,
484
+ ...counters,
485
+ openSpans: store.openSpans.size,
486
+ retainedMessages: source.messages.length,
487
+ messagesObserved: capture === undefined ? null : source.messages.length + capture.droppedMessages,
488
+ },
489
+ conflict: {
490
+ count: session.conflicts ?? (capture === undefined ? null : 0),
491
+ detection,
492
+ },
493
+ };
494
+ }
495
+ }
496
+ const page = (all, offset, limit) => ({
497
+ total: all.length,
498
+ offset,
499
+ limit,
500
+ nextOffset: offset + limit < all.length ? offset + limit : null,
501
+ items: all.slice(offset, offset + limit),
502
+ });
503
+ const byStart = (a, b) => a.startMs - b.startMs || (a.spanId < b.spanId ? -1 : Number(a.spanId > b.spanId));
504
+ /** `measure` descending, ties by start. */
505
+ const ranked = (measure) => (a, b) => measure(b) - measure(a) || byStart(a, b);
506
+ /**
507
+ * Open spans interleave with completed ones by a lower bound of the measure:
508
+ * `elapsedLowerBoundMs` for duration, and for outside-children time the part
509
+ * of it no recorded child covered so far (open children counted up to now).
510
+ */
511
+ const sorter = (sort, analysis) => {
512
+ if (sort === 'start')
513
+ return byStart;
514
+ if (sort === 'duration')
515
+ return ranked((item) => item.durationMs ?? item.elapsedLowerBoundMs);
516
+ return ranked((item) => item.outsideChildrenMs ?? analysis.openOutside.get(item.spanId));
517
+ };
518
+ /** Nearest `limits.ancestry` ancestors, root-most first; with `openOnly`, only the contiguous open ones. */
519
+ const ancestry = (analysis, span, openOnly) => {
520
+ const { store } = analysis;
521
+ const items = [];
522
+ const seen = new Set([span.spanId]);
523
+ let parent = span.parentId === undefined ? undefined : store.spans.get(span.parentId);
524
+ let truncated = false;
525
+ while (parent !== undefined &&
526
+ !seen.has(parent.spanId) &&
527
+ !(openOnly && parent.end !== undefined)) {
528
+ if (items.length === limits.ancestry) {
529
+ truncated = true;
530
+ break;
531
+ }
532
+ seen.add(parent.spanId);
533
+ items.push(ref(analysis, parent));
534
+ parent = parent.parentId === undefined ? undefined : store.spans.get(parent.parentId);
535
+ }
536
+ return { items: items.reverse(), truncated };
537
+ };
538
+ const openAncestors = (analysis, span) => {
539
+ const { items, truncated } = ancestry(analysis, span, true);
540
+ return {
541
+ items: items.map((item) => ({
542
+ ...item,
543
+ elapsedLowerBoundMs: analysis.item(analysis.store.spans.get(item.spanId))
544
+ .elapsedLowerBoundMs,
545
+ })),
546
+ truncated,
547
+ };
548
+ };
549
+ const summarize = (analysis, top) => {
550
+ const items = [...analysis.store.spans.values()].map((span) => analysis.item(span));
551
+ const counts = { total: items.length, ok: 0, error: 0, defect: 0, interrupted: 0, open: 0 };
552
+ const groups = new Map();
553
+ for (const item of items) {
554
+ counts[item.status]++;
555
+ const fullName = analysis.store.spans.get(item.spanId).name;
556
+ let group = groups.get(fullName);
557
+ if (group === undefined) {
558
+ group = {
559
+ name: item.name,
560
+ nameTruncated: item.nameTruncated,
561
+ count: 0,
562
+ completed: 0,
563
+ open: 0,
564
+ failed: 0,
565
+ totalDurationMs: 0,
566
+ maxDurationMs: null,
567
+ totalOutsideChildrenMs: 0,
568
+ };
569
+ groups.set(fullName, group);
570
+ }
571
+ group.count++;
572
+ if (item.error !== null)
573
+ group.failed++;
574
+ if (item.durationMs === null)
575
+ group.open++;
576
+ else {
577
+ group.completed++;
578
+ group.totalDurationMs += item.durationMs;
579
+ group.totalOutsideChildrenMs += item.outsideChildrenMs;
580
+ group.maxDurationMs = Math.max(group.maxDurationMs ?? 0, item.durationMs);
581
+ }
582
+ }
583
+ const byLevel = {};
584
+ for (const log of analysis.store.logs)
585
+ byLevel[log.level] = (byLevel[log.level] ?? 0) + 1;
586
+ const completed = items.filter((item) => item.endMs !== null);
587
+ const open = items.filter((item) => item.endMs === null).sort(sorter('duration', analysis));
588
+ const innermost = open.filter((item) => item.openChildCount === 0);
589
+ const failures = items
590
+ .filter((item) => item.status === 'error' || item.status === 'defect')
591
+ .sort(byStart);
592
+ const { store } = analysis;
593
+ return {
594
+ spans: counts,
595
+ spanEvents: store.stats().events,
596
+ logs: { total: store.logs.length, byLevel },
597
+ memory: memorySummary(analysis, top),
598
+ failures: { total: failures.length, items: failures.slice(0, top) },
599
+ unfinished: {
600
+ open: open.length,
601
+ innermost: {
602
+ total: innermost.length,
603
+ items: innermost.slice(0, top).map((item) => ({
604
+ ...item,
605
+ openAncestors: openAncestors(analysis, store.spans.get(item.spanId)),
606
+ })),
607
+ },
608
+ },
609
+ longest: completed.toSorted(sorter('duration', analysis)).slice(0, top),
610
+ largestOutsideChildren: completed.toSorted(sorter('outsideChildren', analysis)).slice(0, top),
611
+ longestOpen: open.slice(0, top),
612
+ names: {
613
+ total: groups.size,
614
+ items: [...groups.values()]
615
+ .map((group) => ({
616
+ ...group,
617
+ totalDurationMs: ms(group.totalDurationMs),
618
+ totalOutsideChildrenMs: ms(group.totalOutsideChildrenMs),
619
+ }))
620
+ .sort((a, b) => b.totalDurationMs - a.totalDurationMs ||
621
+ (a.name < b.name ? -1 : Number(a.name > b.name)))
622
+ .slice(0, top),
623
+ },
624
+ };
625
+ };
626
+ const memorySummary = (analysis, top) => {
627
+ const { store } = analysis;
628
+ const samples = store.memory;
629
+ const first = samples[0];
630
+ const last = samples.at(-1);
631
+ if (first === undefined || last === undefined)
632
+ return null;
633
+ const peak = samples.find((sample) => sample.heapUsed === store.memoryPeak);
634
+ const gaps = samples.slice(1).map((sample, i) => sample.time - samples[i].time);
635
+ let gapAt = -1;
636
+ for (let i = 0; i < gaps.length; i++)
637
+ if (gapAt < 0 || gaps[i] > gaps[gapAt])
638
+ gapAt = i;
639
+ const sorted = gaps.toSorted((a, b) => a - b);
640
+ const mid = sorted.length >> 1;
641
+ const median = sorted.length % 2 === 1 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
642
+ const active = [...store.spans.values()].filter((span) => span.start <= peak.time && (span.end ?? Number.POSITIVE_INFINITY) >= peak.time);
643
+ const ids = new Set(active.map((span) => span.spanId));
644
+ const innermost = active
645
+ .filter((span) => !span.children.some((id) => ids.has(id)))
646
+ .map((span) => ref(analysis, span))
647
+ .sort((a, b) => a.startMs - b.startMs || (a.spanId < b.spanId ? -1 : 1));
648
+ return {
649
+ samples: samples.length,
650
+ peakHeapUsedBytes: store.memoryPeak,
651
+ peakHeapAtMs: ms(peak.time),
652
+ peakRssBytes: store.memoryRssPeak,
653
+ lastHeapUsedBytes: last.heapUsed,
654
+ firstSampleMs: ms(first.time),
655
+ lastSampleMs: ms(last.time),
656
+ medianIntervalMs: gaps.length === 0 ? null : ms(median),
657
+ maxGapMs: gapAt < 0 ? null : ms(gaps[gapAt]),
658
+ maxGapFromMs: gapAt < 0 ? null : ms(samples[gapAt].time),
659
+ maxGapToMs: gapAt < 0 ? null : ms(samples[gapAt + 1].time),
660
+ spansActiveAtPeak: { total: innermost.length, items: innermost.slice(0, top) },
661
+ };
662
+ };
663
+ const processMemory = (analysis, span) => {
664
+ const until = span.end ?? analysis.now;
665
+ const inRange = analysis.store.memory.filter((sample) => sample.time >= span.start && sample.time <= until);
666
+ const first = inRange[0];
667
+ const last = inRange.at(-1);
668
+ if (first === undefined || last === undefined)
669
+ return null;
670
+ return {
671
+ samples: inRange.length,
672
+ firstSampleMs: ms(first.time),
673
+ lastSampleMs: ms(last.time),
674
+ firstHeapUsedBytes: first.heapUsed,
675
+ lastHeapUsedBytes: last.heapUsed,
676
+ maxHeapUsedBytes: Math.max(...inRange.map((sample) => sample.heapUsed)),
677
+ };
678
+ };
679
+ const ref = (analysis, span) => {
680
+ const item = analysis.item(span);
681
+ return {
682
+ spanId: item.spanId,
683
+ name: item.name,
684
+ nameTruncated: item.nameTruncated,
685
+ status: item.status,
686
+ startMs: item.startMs,
687
+ durationMs: item.durationMs,
688
+ };
689
+ };
690
+ const detail = (analysis, span, childLimit, eventLimit) => {
691
+ const { store } = analysis;
692
+ const external = analysis.externalParents.get(span.spanId);
693
+ let parentInfo = { kind: 'none' };
694
+ if (external !== undefined) {
695
+ parentInfo = { kind: 'external', spanId: external.spanId, traceId: external.traceId };
696
+ }
697
+ else if (span.parentId !== undefined) {
698
+ parentInfo = { kind: 'local', spanId: span.parentId, retained: !span.orphaned };
699
+ }
700
+ const children = span.children
701
+ .flatMap((id) => {
702
+ const child = store.spans.get(id);
703
+ return child === undefined ? [] : [analysis.item(child)];
704
+ })
705
+ .sort(byStart);
706
+ const events = span.events.toSorted((a, b) => a.time - b.time);
707
+ const stack = span.outcome?._tag === 'Failure' && span.outcome.stack !== undefined
708
+ ? cut(span.outcome.stack, limits.stackChars)
709
+ : undefined;
710
+ return {
711
+ ...analysis.item(span),
712
+ attributes: bound(span.attributes),
713
+ stack: stack?.text ?? null,
714
+ stackTruncated: stack?.truncated ?? false,
715
+ parent: parentInfo,
716
+ ancestry: ancestry(analysis, span, false),
717
+ children: { total: children.length, items: children.slice(0, childLimit) },
718
+ events: {
719
+ total: events.length,
720
+ items: events.slice(0, eventLimit).map((event) => {
721
+ const name = cut(event.name, limits.nameChars);
722
+ return {
723
+ name: name.text,
724
+ nameTruncated: name.truncated,
725
+ timeMs: ms(event.time),
726
+ attributes: bound(event.attributes),
727
+ };
728
+ }),
729
+ },
730
+ processMemory: processMemory(analysis, span),
731
+ };
732
+ };
733
+ /** Span ids of `root` and every retained descendant. */
734
+ const subtree = (store, root) => {
735
+ const ids = new Set();
736
+ const stack = [root];
737
+ while (stack.length > 0) {
738
+ const span = stack.pop();
739
+ if (ids.has(span.spanId))
740
+ continue;
741
+ ids.add(span.spanId);
742
+ for (const id of span.children) {
743
+ const child = store.spans.get(id);
744
+ if (child !== undefined)
745
+ stack.push(child);
746
+ }
747
+ }
748
+ return ids;
749
+ };
750
+ const spanNotFound = (op, spanId, analysis) => failure(op, 'SpanNotFound', 'The requested span is not in the retained data of this session (see error.spanId).', 'List span ids with the spans query. A span evicted by capacity or never received cannot be recovered; check completeness.', {
751
+ spanId,
752
+ sessionId: analysis.source.session.sessionId,
753
+ completeness: analysis.context().completeness,
754
+ });
755
+ const plural = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`;
756
+ /** Facts a reader of `summary` could otherwise miss, most important first. */
757
+ const notices = (context, { unfinished, memory }) => {
758
+ const out = [];
759
+ const { termination, completeness, time } = context;
760
+ if (unfinished.open > 0) {
761
+ const spans = plural(unfinished.open, 'span');
762
+ const first = unfinished.innermost.items[0];
763
+ const outer = first?.openAncestors.items[0];
764
+ const within = outer === undefined
765
+ ? ''
766
+ : `, within "${outer.name}" (spanId ${outer.spanId}), open for at least ${outer.elapsedLowerBoundMs} ms`;
767
+ const position = first === undefined
768
+ ? ''
769
+ : ` Last recorded position: "${first.name}" (spanId ${first.spanId}), open for at least ${first.elapsedLowerBoundMs} ms${within}, innermost of ${plural(unfinished.innermost.total, 'open chain')}; see result.unfinished.`;
770
+ let message;
771
+ if (termination.state === 'active') {
772
+ message = `${spans} had no recorded end at snapshot time and the program is still connected; they may still end.${position}`;
773
+ }
774
+ else {
775
+ const ended = termination.state === 'ended'
776
+ ? `the collector recorded a disconnect at ${termination.endedAtMs} ms${termination.unobservedTailMs === null ? '' : `, ${termination.unobservedTailMs} ms after the last retained message, with nothing retained in between`}`
777
+ : 'no session end time is on record';
778
+ message = `${spans} had no recorded end; ${ended}.${position} The protocol has no end-of-session message, so a crash, a kill, an exit without closing spans and a dropped connection look the same; missing ends alone do not establish which, and an end may also have been sent but lost.`;
779
+ }
780
+ out.push({ code: 'openSpans', message });
781
+ out.push({
782
+ code: 'rankingsCompletedOnly',
783
+ message: `result.longest and result.largestOutsideChildren rank completed spans only; the ${spans} without an end are in result.unfinished and result.longestOpen.`,
784
+ });
785
+ }
786
+ const evicted = completeness.collectorDroppedMessages ?? 0;
787
+ if (evicted > 0) {
788
+ out.push({
789
+ code: 'collectorEvicted',
790
+ message: `The collector evicted the oldest ${plural(evicted, 'message')} of this session at its capacity: data before observedFromMs (${time.observedFromMs} ms) is missing, so earlier spans, logs and memory samples are absent and retained spans may lack their start or parent.`,
791
+ });
792
+ }
793
+ // ponytail: fixed 10x threshold; the sampling interval is not in the protocol.
794
+ if (memory?.maxGapMs != null && memory.maxGapMs > 10 * memory.medianIntervalMs) {
795
+ out.push({
796
+ code: 'memorySamplingGap',
797
+ message: `Memory samples are ${memory.medianIntervalMs} ms apart at the median, but no sample was retained for ${memory.maxGapMs} ms (from ${memory.maxGapFromMs} ms to ${memory.maxGapToMs} ms); process memory in that interval is unknown. The cause is not recorded. Possible explanations: synchronous work blocked the event loop so the sampling timer could not run, or the client's outbound queue was full and dropped samples (see completeness.clientDroppedMessages).`,
798
+ });
799
+ }
800
+ return out;
801
+ };
802
+ // ---------------------------------------------------------------------------
803
+ // Entry points
804
+ // ---------------------------------------------------------------------------
805
+ /**
806
+ * Lists `sessions`, newest start first (ties by id). Discovery only: a caller
807
+ * that chose its session ID should query it directly instead.
808
+ */
809
+ export const listSessions = (source, sessions, request) => {
810
+ const limit = request.limit ?? limits.sessions.default;
811
+ const offset = request.offset ?? 0;
812
+ const items = sessions
813
+ .map((session) => ({
814
+ sessionId: session.sessionId,
815
+ ...describe(session),
816
+ active: session.active,
817
+ startedAtEpochMillis: session.clock.wallClockEpochMillis,
818
+ endedAtEpochMillis: session.endedAtEpochMillis ?? null,
819
+ conflicts: session.conflicts ?? null,
820
+ }))
821
+ .sort((a, b) => b.startedAtEpochMillis - a.startedAtEpochMillis ||
822
+ (a.sessionId < b.sessionId ? -1 : Number(a.sessionId > b.sessionId)));
823
+ return limitResponse({
824
+ ok: true,
825
+ apiVersion,
826
+ op: 'sessions',
827
+ query: { op: 'sessions', limit, offset },
828
+ source,
829
+ result: page(items, offset, limit),
830
+ });
831
+ };
832
+ /**
833
+ * Answers a per-session query against one frozen source. The caller has
834
+ * already matched `request.sessionId` to `source` exactly; this refuses a
835
+ * session with a recorded ID conflict instead of answering for it. The
836
+ * response is held to {@link limits.responseBytes}.
837
+ */
838
+ export const run = (source, request) => limitResponse(answer(source, request));
839
+ /**
840
+ * The applied request: `op` and the selected `sessionId` first, then the
841
+ * caller's filters and the filled-in defaults, in a fixed key order so the
842
+ * same query serializes identically live and from a file.
843
+ */
844
+ const echo = (request, sessionId, applied) => Object.assign({ op: request.op, sessionId }, request, applied, { sessionId });
845
+ const answer = (source, request) => {
846
+ const { op } = request;
847
+ const sessionId = source.session.sessionId;
848
+ const conflicts = source.session.conflicts ?? 0;
849
+ if (conflicts > 0) {
850
+ return failure(op, 'SessionConflict', `This session ID was announced by ${conflicts} other run(s) besides the one recorded; the data cannot be attributed to your run.`, 'Relaunch with a new, unique EFFECT_INSPECT_SESSION_ID and query that ID.', { sessionId, conflicts });
851
+ }
852
+ const analysis = new Analysis(source);
853
+ const context = analysis.context();
854
+ const { store } = analysis;
855
+ switch (request.op) {
856
+ case 'summary': {
857
+ const top = request.top ?? limits.top.default;
858
+ const result = summarize(analysis, top);
859
+ return {
860
+ ok: true,
861
+ apiVersion,
862
+ op: 'summary',
863
+ query: { op, sessionId, top },
864
+ notices: notices(context, result),
865
+ ...context,
866
+ result,
867
+ };
868
+ }
869
+ case 'spans': {
870
+ const status = request.status ?? 'any';
871
+ const sort = request.sort ?? 'start';
872
+ const limit = request.limit ?? limits.spans.default;
873
+ const offset = request.offset ?? 0;
874
+ const needle = request.name?.toLowerCase();
875
+ const from = request.fromMs ?? Number.NEGATIVE_INFINITY;
876
+ const to = request.toMs ?? Number.POSITIVE_INFINITY;
877
+ const matched = [];
878
+ for (const span of store.spans.values()) {
879
+ const item = analysis.item(span);
880
+ if (!matchesStatus(item.status, status))
881
+ continue;
882
+ // Matches the full name, not the shortened one in the response.
883
+ if (needle !== undefined && !span.name.toLowerCase().includes(needle))
884
+ continue;
885
+ if (item.startMs > to || (item.endMs ?? analysis.now) < from)
886
+ continue;
887
+ const measured = item.durationMs ?? item.elapsedLowerBoundMs;
888
+ if (request.minDurationMs !== undefined && measured < request.minDurationMs)
889
+ continue;
890
+ matched.push(item);
891
+ }
892
+ matched.sort(sorter(sort, analysis));
893
+ return {
894
+ ok: true,
895
+ apiVersion,
896
+ op: 'spans',
897
+ query: echo(request, sessionId, { status, sort, limit, offset }),
898
+ ...context,
899
+ window: request.fromMs === undefined && request.toMs === undefined
900
+ ? null
901
+ : {
902
+ fromMs: request.fromMs ?? null,
903
+ toMs: request.toMs ?? null,
904
+ match: 'overlap',
905
+ inclusive: true,
906
+ timings: 'fullSpan',
907
+ },
908
+ result: page(matched, offset, limit),
909
+ };
910
+ }
911
+ case 'span': {
912
+ const span = store.spans.get(request.spanId);
913
+ if (span === undefined)
914
+ return spanNotFound(op, request.spanId, analysis);
915
+ const children = request.children ?? limits.children.default;
916
+ const events = request.events ?? limits.events.default;
917
+ return {
918
+ ok: true,
919
+ apiVersion,
920
+ op: 'span',
921
+ query: { op, sessionId, spanId: request.spanId, children, events },
922
+ ...context,
923
+ result: detail(analysis, span, children, events),
924
+ };
925
+ }
926
+ case 'logs': {
927
+ const limit = request.limit ?? limits.logs.default;
928
+ const offset = request.offset ?? 0;
929
+ const scope = request.spanId === undefined ? undefined : (request.scope ?? 'subtree');
930
+ let spans;
931
+ if (request.spanId !== undefined) {
932
+ const span = store.spans.get(request.spanId);
933
+ if (span === undefined)
934
+ return spanNotFound(op, request.spanId, analysis);
935
+ spans = scope === 'span' ? new Set([span.spanId]) : subtree(store, span);
936
+ }
937
+ const minLevel = request.minLevel === undefined ? -1 : logLevels.indexOf(request.minLevel);
938
+ const from = request.fromMs ?? Number.NEGATIVE_INFINITY;
939
+ const to = request.toMs ?? Number.POSITIVE_INFINITY;
940
+ const matched = store.logs
941
+ .filter((log) => (spans === undefined || (log.spanId !== undefined && spans.has(log.spanId))) &&
942
+ (minLevel === -1 ||
943
+ logLevels.indexOf(log.level) >= minLevel) &&
944
+ log.time >= from &&
945
+ log.time <= to)
946
+ // Stable: equal times keep arrival order.
947
+ .sort((a, b) => a.time - b.time);
948
+ const items = matched.slice(offset, offset + limit).map((log) => {
949
+ const { text, truncated } = cut(typeof log.message === 'string' ? log.message : JSON.stringify(log.message), limits.logMessageChars);
950
+ const spanName = log.spanId === undefined ? undefined : store.spans.get(log.spanId)?.name;
951
+ const shortName = spanName === undefined ? undefined : cut(spanName, limits.nameChars);
952
+ return {
953
+ timeMs: ms(log.time),
954
+ level: log.level,
955
+ message: text,
956
+ messageTruncated: truncated,
957
+ spanId: log.spanId ?? null,
958
+ spanName: shortName?.text ?? null,
959
+ spanNameTruncated: shortName?.truncated ?? false,
960
+ fiberId: log.fiberId ?? null,
961
+ annotations: bound(log.annotations),
962
+ };
963
+ });
964
+ return {
965
+ ok: true,
966
+ apiVersion,
967
+ op: 'logs',
968
+ query: echo(request, sessionId, {
969
+ ...(scope === undefined ? {} : { scope }),
970
+ limit,
971
+ offset,
972
+ }),
973
+ ...context,
974
+ window: request.fromMs === undefined && request.toMs === undefined
975
+ ? null
976
+ : {
977
+ fromMs: request.fromMs ?? null,
978
+ toMs: request.toMs ?? null,
979
+ match: 'within',
980
+ inclusive: true,
981
+ },
982
+ result: { ...page(matched, offset, limit), items },
983
+ };
984
+ }
985
+ }
986
+ };
987
+ const sessionNotFound = (op, requested, where, details) => failure(op, 'SessionNotFound', `No session with the requested ID (error.sessionId) ${where}. No other session was substituted.`, 'Check the exact ID the program was launched with (EFFECT_INSPECT_SESSION_ID or the sessionId option), that it uses Inspect.layer() against this collector, and that it has started; or list sessions.', { ...details, sessionId: requested });
988
+ /** Answers a request against trace-file text: the offline equivalent of a collector query. */
989
+ export const queryFile = (text, file, input) => limitResponse(answerFile(text, file, input));
990
+ const answerFile = (text, file, input) => {
991
+ const decoded = decodeRequest(input);
992
+ if (Result.isFailure(decoded))
993
+ return decoded.failure;
994
+ const request = decoded.success;
995
+ const loaded = fromTraceFile(text, file);
996
+ if (Result.isFailure(loaded))
997
+ return { ...loaded.failure, op: request.op };
998
+ const source = loaded.success;
999
+ if (request.op === 'sessions') {
1000
+ return listSessions({ kind: 'file', file }, [source.session], request);
1001
+ }
1002
+ const saved = source.session.sessionId;
1003
+ // A trace re-saved from the browser carries its `loaded:` display prefix.
1004
+ if (request.sessionId !== undefined &&
1005
+ request.sessionId !== saved &&
1006
+ `loaded:${request.sessionId}` !== saved) {
1007
+ return sessionNotFound(request.op, request.sessionId, 'is in this file', {
1008
+ file,
1009
+ fileSessionId: saved,
1010
+ });
1011
+ }
1012
+ return run(source, request);
1013
+ };
1014
+ /** The live `SessionNotFound` failure, shared by the collector's handlers. */
1015
+ export const liveSessionNotFound = (op, sessionId) => sessionNotFound(op, sessionId, 'is known to this collector');
1016
+ /** The live failure for a per-session request without a `sessionId`. */
1017
+ export const sessionRequired = (op) => failure(op, 'InvalidRequest', 'Live queries must name a session: sessionId is required. The newest session is never assumed.', 'Pass the exact session ID the program was launched with, or list sessions to discover one.');