effect-inspect 0.1.1 → 0.2.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/README.md +68 -0
- package/app/dist/client/assets/{index-smV05cfr.js → index-T-bCzOWw.js} +2 -2
- package/app/dist/client/assets/routes-C2qO8k2W.js +5 -0
- package/app/dist/server/assets/{_tanstack-start-manifest_v-CFQ3DEVN.js → _tanstack-start-manifest_v-B2BMiICr.js} +3 -3
- package/app/dist/server/assets/{router-CN98Ramo.js → router-dMcw-pHq.js} +1 -1
- package/app/dist/server/assets/{routes-eZ4XqxE9.js → routes-BmnrEVoN.js} +203 -157
- package/app/dist/server/server.js +2 -2
- package/dist/cli/QueryCommands.d.ts +127 -0
- package/dist/cli/QueryCommands.js +768 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +150 -8
- package/dist/client/Client.d.ts +42 -1
- package/dist/client/Client.js +88 -4
- package/dist/collector/QueryApi.d.ts +29 -0
- package/dist/collector/QueryApi.js +91 -0
- package/dist/collector/Server.d.ts +1 -1
- package/dist/collector/Server.js +15 -8
- package/dist/collector/Store.d.ts +56 -16
- package/dist/collector/Store.js +37 -11
- package/dist/protocol/Codec.d.ts +10 -0
- package/dist/protocol/Schema.d.ts +118 -1
- package/dist/protocol/Schema.js +53 -1
- package/dist/query/Client.d.ts +30 -0
- package/dist/query/Client.js +74 -0
- package/dist/query/Query.d.ts +599 -0
- package/dist/query/Query.js +876 -0
- package/dist/trace/Timing.d.ts +18 -0
- package/dist/trace/Timing.js +57 -0
- package/dist/trace/TraceFile.d.ts +54 -0
- package/dist/trace/TraceFile.js +86 -0
- package/dist/trace/TraceStore.d.ts +202 -0
- package/dist/trace/TraceStore.js +328 -0
- package/package.json +3 -1
- package/app/dist/client/assets/routes-TKgeFdSW.js +0 -5
|
@@ -0,0 +1,876 @@
|
|
|
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
|
+
constructor(source) {
|
|
320
|
+
this.source = source;
|
|
321
|
+
const store = this.store;
|
|
322
|
+
// Anchor at the session clock, not the first retained message, so times
|
|
323
|
+
// are identical across snapshots, evictions and exported files.
|
|
324
|
+
store.origin = source.session.clock.startTime;
|
|
325
|
+
store.epochOrigin = source.session.clock.wallClockEpochMillis;
|
|
326
|
+
const started = new Set();
|
|
327
|
+
const early = new Set();
|
|
328
|
+
let clientDropped = 0;
|
|
329
|
+
for (const message of source.messages) {
|
|
330
|
+
if (message._tag === 'SpanStart') {
|
|
331
|
+
started.add(message.spanId);
|
|
332
|
+
if (message.parent?._tag === 'ExternalParent') {
|
|
333
|
+
this.externalParents.set(message.spanId, message.parent);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
else if ((message._tag === 'SpanEnd' || message._tag === 'SpanEvent') &&
|
|
337
|
+
!started.has(message.spanId)) {
|
|
338
|
+
early.add(message.spanId);
|
|
339
|
+
}
|
|
340
|
+
else if (message._tag === 'Log') {
|
|
341
|
+
const dropped = message.annotations['effect_inspect.dropped'];
|
|
342
|
+
if (typeof dropped === 'number')
|
|
343
|
+
clientDropped += dropped;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
let outOfOrder = 0;
|
|
347
|
+
for (const id of early)
|
|
348
|
+
if (started.has(id))
|
|
349
|
+
outOfOrder++;
|
|
350
|
+
this.missingStarts = early.size - outOfOrder;
|
|
351
|
+
this.outOfOrder = outOfOrder;
|
|
352
|
+
this.clientDropped = clientDropped;
|
|
353
|
+
store.applyAll(source.messages);
|
|
354
|
+
// Computed here rather than read from the store's `duration`, which is
|
|
355
|
+
// floored at 0 for the renderer; times before the clock anchor are real.
|
|
356
|
+
let from = Number.POSITIVE_INFINITY;
|
|
357
|
+
let until = Number.NEGATIVE_INFINITY;
|
|
358
|
+
const observe = (time) => {
|
|
359
|
+
if (time < from)
|
|
360
|
+
from = time;
|
|
361
|
+
if (time > until)
|
|
362
|
+
until = time;
|
|
363
|
+
};
|
|
364
|
+
for (const span of store.spans.values()) {
|
|
365
|
+
observe(span.start);
|
|
366
|
+
if (span.end !== undefined)
|
|
367
|
+
observe(span.end);
|
|
368
|
+
for (const event of span.events)
|
|
369
|
+
observe(event.time);
|
|
370
|
+
}
|
|
371
|
+
for (const log of store.logs) {
|
|
372
|
+
observe(log.time);
|
|
373
|
+
if (log.spanId !== undefined) {
|
|
374
|
+
this.logCounts.set(log.spanId, (this.logCounts.get(log.spanId) ?? 0) + 1);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
for (const sample of store.memory)
|
|
378
|
+
observe(sample.time);
|
|
379
|
+
this.observedFrom = from === Number.POSITIVE_INFINITY ? undefined : from;
|
|
380
|
+
this.observedUntil = until === Number.NEGATIVE_INFINITY ? undefined : until;
|
|
381
|
+
this.now = this.observedUntil ?? 0;
|
|
382
|
+
}
|
|
383
|
+
item(span) {
|
|
384
|
+
const cached = this.items.get(span.spanId);
|
|
385
|
+
if (cached !== undefined)
|
|
386
|
+
return cached;
|
|
387
|
+
const status = statusOf(span);
|
|
388
|
+
let openChildCount = 0;
|
|
389
|
+
for (const id of span.children) {
|
|
390
|
+
if (this.store.spans.get(id)?.end === undefined)
|
|
391
|
+
openChildCount++;
|
|
392
|
+
}
|
|
393
|
+
const closed = span.end !== undefined;
|
|
394
|
+
const { total, self } = timings(this.store, span, this.now);
|
|
395
|
+
const error = span.outcome?._tag === 'Failure'
|
|
396
|
+
? (() => {
|
|
397
|
+
const { text, truncated } = cut(span.outcome.error, limits.errorChars);
|
|
398
|
+
return { kind: span.outcome.kind, message: text, messageTruncated: truncated };
|
|
399
|
+
})()
|
|
400
|
+
: null;
|
|
401
|
+
const name = cut(span.name, limits.nameChars);
|
|
402
|
+
const item = {
|
|
403
|
+
spanId: span.spanId,
|
|
404
|
+
traceId: span.traceId,
|
|
405
|
+
name: name.text,
|
|
406
|
+
nameTruncated: name.truncated,
|
|
407
|
+
kind: span.kind,
|
|
408
|
+
parentSpanId: span.parentId ?? null,
|
|
409
|
+
status,
|
|
410
|
+
startMs: ms(span.start),
|
|
411
|
+
endMs: closed ? ms(span.end) : null,
|
|
412
|
+
durationMs: closed ? ms(total) : null,
|
|
413
|
+
childCoveredMs: closed ? ms(total - self) : null,
|
|
414
|
+
outsideChildrenMs: closed ? ms(self) : null,
|
|
415
|
+
elapsedLowerBoundMs: closed ? null : ms(this.now - span.start),
|
|
416
|
+
childCount: span.children.length,
|
|
417
|
+
openChildCount,
|
|
418
|
+
eventCount: span.events.length,
|
|
419
|
+
logCount: this.logCounts.get(span.spanId) ?? 0,
|
|
420
|
+
error,
|
|
421
|
+
};
|
|
422
|
+
this.items.set(span.spanId, item);
|
|
423
|
+
return item;
|
|
424
|
+
}
|
|
425
|
+
context() {
|
|
426
|
+
const { source, store } = this;
|
|
427
|
+
const { session, capture } = source;
|
|
428
|
+
const counters = {
|
|
429
|
+
collectorDroppedMessages: capture?.droppedMessages ?? null,
|
|
430
|
+
collectorSkippedLines: capture?.skippedLines ?? null,
|
|
431
|
+
clientDroppedMessages: this.clientDropped,
|
|
432
|
+
fileTruncatedLines: source.truncatedLines,
|
|
433
|
+
spansMissingStart: this.missingStarts,
|
|
434
|
+
spansOutOfOrder: this.outOfOrder,
|
|
435
|
+
spansMissingParent: [...store.spans.values()].filter((span) => span.orphaned).length,
|
|
436
|
+
};
|
|
437
|
+
const lost = Object.values(counters).some((value) => value !== null && value > 0);
|
|
438
|
+
let status = 'noLossRecorded';
|
|
439
|
+
if (lost)
|
|
440
|
+
status = 'lossRecorded';
|
|
441
|
+
else if (capture === undefined)
|
|
442
|
+
status = 'unknown';
|
|
443
|
+
let detection = 'unknown';
|
|
444
|
+
if (capture !== undefined)
|
|
445
|
+
detection = capture.conflictDetection ? 'enforced' : 'unavailable';
|
|
446
|
+
return {
|
|
447
|
+
source: {
|
|
448
|
+
kind: source.kind,
|
|
449
|
+
file: source.file ?? null,
|
|
450
|
+
sessionId: session.sessionId,
|
|
451
|
+
...describe(session),
|
|
452
|
+
active: session.active,
|
|
453
|
+
startedAtEpochMillis: session.clock.wallClockEpochMillis,
|
|
454
|
+
endedAtEpochMillis: session.endedAtEpochMillis ?? null,
|
|
455
|
+
snapshotAtEpochMillis: source.snapshotAtEpochMillis,
|
|
456
|
+
},
|
|
457
|
+
time: {
|
|
458
|
+
unit: 'ms',
|
|
459
|
+
reference: 'sessionStart',
|
|
460
|
+
observedFromMs: this.observedFrom === undefined ? null : ms(this.observedFrom),
|
|
461
|
+
observedUntilMs: this.observedUntil === undefined ? null : ms(this.observedUntil),
|
|
462
|
+
},
|
|
463
|
+
completeness: {
|
|
464
|
+
status,
|
|
465
|
+
...counters,
|
|
466
|
+
openSpans: store.openSpans.size,
|
|
467
|
+
retainedMessages: source.messages.length,
|
|
468
|
+
messagesObserved: capture === undefined ? null : source.messages.length + capture.droppedMessages,
|
|
469
|
+
},
|
|
470
|
+
conflict: {
|
|
471
|
+
count: session.conflicts ?? (capture === undefined ? null : 0),
|
|
472
|
+
detection,
|
|
473
|
+
},
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
const page = (all, offset, limit) => ({
|
|
478
|
+
total: all.length,
|
|
479
|
+
offset,
|
|
480
|
+
limit,
|
|
481
|
+
nextOffset: offset + limit < all.length ? offset + limit : null,
|
|
482
|
+
items: all.slice(offset, offset + limit),
|
|
483
|
+
});
|
|
484
|
+
const byStart = (a, b) => a.startMs - b.startMs || (a.spanId < b.spanId ? -1 : Number(a.spanId > b.spanId));
|
|
485
|
+
/** Completed spans by `measure` descending, then open spans by lower bound; ties by start. */
|
|
486
|
+
const ranked = (measure) => (a, b) => {
|
|
487
|
+
const aOpen = a.endMs === null;
|
|
488
|
+
const bOpen = b.endMs === null;
|
|
489
|
+
if (aOpen !== bOpen)
|
|
490
|
+
return aOpen ? 1 : -1;
|
|
491
|
+
const key = aOpen ? 'elapsedLowerBoundMs' : measure;
|
|
492
|
+
return b[key] - a[key] || byStart(a, b);
|
|
493
|
+
};
|
|
494
|
+
const sorters = {
|
|
495
|
+
start: byStart,
|
|
496
|
+
duration: ranked('durationMs'),
|
|
497
|
+
outsideChildren: ranked('outsideChildrenMs'),
|
|
498
|
+
};
|
|
499
|
+
const summarize = (analysis, top) => {
|
|
500
|
+
const items = [...analysis.store.spans.values()].map((span) => analysis.item(span));
|
|
501
|
+
const counts = { total: items.length, ok: 0, error: 0, defect: 0, interrupted: 0, open: 0 };
|
|
502
|
+
const groups = new Map();
|
|
503
|
+
for (const item of items) {
|
|
504
|
+
counts[item.status]++;
|
|
505
|
+
const fullName = analysis.store.spans.get(item.spanId).name;
|
|
506
|
+
let group = groups.get(fullName);
|
|
507
|
+
if (group === undefined) {
|
|
508
|
+
group = {
|
|
509
|
+
name: item.name,
|
|
510
|
+
nameTruncated: item.nameTruncated,
|
|
511
|
+
count: 0,
|
|
512
|
+
completed: 0,
|
|
513
|
+
open: 0,
|
|
514
|
+
failed: 0,
|
|
515
|
+
totalDurationMs: 0,
|
|
516
|
+
maxDurationMs: null,
|
|
517
|
+
totalOutsideChildrenMs: 0,
|
|
518
|
+
};
|
|
519
|
+
groups.set(fullName, group);
|
|
520
|
+
}
|
|
521
|
+
group.count++;
|
|
522
|
+
if (item.error !== null)
|
|
523
|
+
group.failed++;
|
|
524
|
+
if (item.durationMs === null)
|
|
525
|
+
group.open++;
|
|
526
|
+
else {
|
|
527
|
+
group.completed++;
|
|
528
|
+
group.totalDurationMs += item.durationMs;
|
|
529
|
+
group.totalOutsideChildrenMs += item.outsideChildrenMs;
|
|
530
|
+
group.maxDurationMs = Math.max(group.maxDurationMs ?? 0, item.durationMs);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
const byLevel = {};
|
|
534
|
+
for (const log of analysis.store.logs)
|
|
535
|
+
byLevel[log.level] = (byLevel[log.level] ?? 0) + 1;
|
|
536
|
+
const completed = items.filter((item) => item.endMs !== null);
|
|
537
|
+
const failures = items
|
|
538
|
+
.filter((item) => item.status === 'error' || item.status === 'defect')
|
|
539
|
+
.sort(byStart);
|
|
540
|
+
const { store } = analysis;
|
|
541
|
+
const last = store.memory.at(-1);
|
|
542
|
+
return {
|
|
543
|
+
spans: counts,
|
|
544
|
+
spanEvents: store.stats().events,
|
|
545
|
+
logs: { total: store.logs.length, byLevel },
|
|
546
|
+
memory: last === undefined
|
|
547
|
+
? null
|
|
548
|
+
: {
|
|
549
|
+
samples: store.memory.length,
|
|
550
|
+
peakHeapUsedBytes: store.memoryPeak,
|
|
551
|
+
peakRssBytes: store.memoryRssPeak,
|
|
552
|
+
lastHeapUsedBytes: last.heapUsed,
|
|
553
|
+
},
|
|
554
|
+
failures: { total: failures.length, items: failures.slice(0, top) },
|
|
555
|
+
longest: completed.toSorted(sorters.duration).slice(0, top),
|
|
556
|
+
largestOutsideChildren: completed.toSorted(sorters.outsideChildren).slice(0, top),
|
|
557
|
+
longestOpen: items
|
|
558
|
+
.filter((item) => item.endMs === null)
|
|
559
|
+
.sort(sorters.duration)
|
|
560
|
+
.slice(0, top),
|
|
561
|
+
names: {
|
|
562
|
+
total: groups.size,
|
|
563
|
+
items: [...groups.values()]
|
|
564
|
+
.map((group) => ({
|
|
565
|
+
...group,
|
|
566
|
+
totalDurationMs: ms(group.totalDurationMs),
|
|
567
|
+
totalOutsideChildrenMs: ms(group.totalOutsideChildrenMs),
|
|
568
|
+
}))
|
|
569
|
+
.sort((a, b) => b.totalDurationMs - a.totalDurationMs ||
|
|
570
|
+
(a.name < b.name ? -1 : Number(a.name > b.name)))
|
|
571
|
+
.slice(0, top),
|
|
572
|
+
},
|
|
573
|
+
};
|
|
574
|
+
};
|
|
575
|
+
const ref = (analysis, span) => {
|
|
576
|
+
const item = analysis.item(span);
|
|
577
|
+
return {
|
|
578
|
+
spanId: item.spanId,
|
|
579
|
+
name: item.name,
|
|
580
|
+
nameTruncated: item.nameTruncated,
|
|
581
|
+
status: item.status,
|
|
582
|
+
startMs: item.startMs,
|
|
583
|
+
durationMs: item.durationMs,
|
|
584
|
+
};
|
|
585
|
+
};
|
|
586
|
+
const detail = (analysis, span, childLimit, eventLimit) => {
|
|
587
|
+
const { store } = analysis;
|
|
588
|
+
const ancestry = [];
|
|
589
|
+
const seen = new Set([span.spanId]);
|
|
590
|
+
let parent = span.parentId === undefined ? undefined : store.spans.get(span.parentId);
|
|
591
|
+
let truncated = false;
|
|
592
|
+
while (parent !== undefined && !seen.has(parent.spanId)) {
|
|
593
|
+
if (ancestry.length === limits.ancestry) {
|
|
594
|
+
truncated = true;
|
|
595
|
+
break;
|
|
596
|
+
}
|
|
597
|
+
seen.add(parent.spanId);
|
|
598
|
+
ancestry.push(ref(analysis, parent));
|
|
599
|
+
parent = parent.parentId === undefined ? undefined : store.spans.get(parent.parentId);
|
|
600
|
+
}
|
|
601
|
+
const external = analysis.externalParents.get(span.spanId);
|
|
602
|
+
let parentInfo = { kind: 'none' };
|
|
603
|
+
if (external !== undefined) {
|
|
604
|
+
parentInfo = { kind: 'external', spanId: external.spanId, traceId: external.traceId };
|
|
605
|
+
}
|
|
606
|
+
else if (span.parentId !== undefined) {
|
|
607
|
+
parentInfo = { kind: 'local', spanId: span.parentId, retained: !span.orphaned };
|
|
608
|
+
}
|
|
609
|
+
const children = span.children
|
|
610
|
+
.flatMap((id) => {
|
|
611
|
+
const child = store.spans.get(id);
|
|
612
|
+
return child === undefined ? [] : [analysis.item(child)];
|
|
613
|
+
})
|
|
614
|
+
.sort(byStart);
|
|
615
|
+
const events = span.events.toSorted((a, b) => a.time - b.time);
|
|
616
|
+
const stack = span.outcome?._tag === 'Failure' && span.outcome.stack !== undefined
|
|
617
|
+
? cut(span.outcome.stack, limits.stackChars)
|
|
618
|
+
: undefined;
|
|
619
|
+
return {
|
|
620
|
+
...analysis.item(span),
|
|
621
|
+
attributes: bound(span.attributes),
|
|
622
|
+
stack: stack?.text ?? null,
|
|
623
|
+
stackTruncated: stack?.truncated ?? false,
|
|
624
|
+
parent: parentInfo,
|
|
625
|
+
ancestry: { items: ancestry.reverse(), truncated },
|
|
626
|
+
children: { total: children.length, items: children.slice(0, childLimit) },
|
|
627
|
+
events: {
|
|
628
|
+
total: events.length,
|
|
629
|
+
items: events.slice(0, eventLimit).map((event) => {
|
|
630
|
+
const name = cut(event.name, limits.nameChars);
|
|
631
|
+
return {
|
|
632
|
+
name: name.text,
|
|
633
|
+
nameTruncated: name.truncated,
|
|
634
|
+
timeMs: ms(event.time),
|
|
635
|
+
attributes: bound(event.attributes),
|
|
636
|
+
};
|
|
637
|
+
}),
|
|
638
|
+
},
|
|
639
|
+
};
|
|
640
|
+
};
|
|
641
|
+
/** Span ids of `root` and every retained descendant. */
|
|
642
|
+
const subtree = (store, root) => {
|
|
643
|
+
const ids = new Set();
|
|
644
|
+
const stack = [root];
|
|
645
|
+
while (stack.length > 0) {
|
|
646
|
+
const span = stack.pop();
|
|
647
|
+
if (ids.has(span.spanId))
|
|
648
|
+
continue;
|
|
649
|
+
ids.add(span.spanId);
|
|
650
|
+
for (const id of span.children) {
|
|
651
|
+
const child = store.spans.get(id);
|
|
652
|
+
if (child !== undefined)
|
|
653
|
+
stack.push(child);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
return ids;
|
|
657
|
+
};
|
|
658
|
+
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.', {
|
|
659
|
+
spanId,
|
|
660
|
+
sessionId: analysis.source.session.sessionId,
|
|
661
|
+
completeness: analysis.context().completeness,
|
|
662
|
+
});
|
|
663
|
+
// ---------------------------------------------------------------------------
|
|
664
|
+
// Entry points
|
|
665
|
+
// ---------------------------------------------------------------------------
|
|
666
|
+
/**
|
|
667
|
+
* Lists `sessions`, newest start first (ties by id). Discovery only: a caller
|
|
668
|
+
* that chose its session ID should query it directly instead.
|
|
669
|
+
*/
|
|
670
|
+
export const listSessions = (source, sessions, request) => {
|
|
671
|
+
const limit = request.limit ?? limits.sessions.default;
|
|
672
|
+
const offset = request.offset ?? 0;
|
|
673
|
+
const items = sessions
|
|
674
|
+
.map((session) => ({
|
|
675
|
+
sessionId: session.sessionId,
|
|
676
|
+
...describe(session),
|
|
677
|
+
active: session.active,
|
|
678
|
+
startedAtEpochMillis: session.clock.wallClockEpochMillis,
|
|
679
|
+
endedAtEpochMillis: session.endedAtEpochMillis ?? null,
|
|
680
|
+
conflicts: session.conflicts ?? null,
|
|
681
|
+
}))
|
|
682
|
+
.sort((a, b) => b.startedAtEpochMillis - a.startedAtEpochMillis ||
|
|
683
|
+
(a.sessionId < b.sessionId ? -1 : Number(a.sessionId > b.sessionId)));
|
|
684
|
+
return limitResponse({
|
|
685
|
+
ok: true,
|
|
686
|
+
apiVersion,
|
|
687
|
+
op: 'sessions',
|
|
688
|
+
query: { op: 'sessions', limit, offset },
|
|
689
|
+
source,
|
|
690
|
+
result: page(items, offset, limit),
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
/**
|
|
694
|
+
* Answers a per-session query against one frozen source. The caller has
|
|
695
|
+
* already matched `request.sessionId` to `source` exactly; this refuses a
|
|
696
|
+
* session with a recorded ID conflict instead of answering for it. The
|
|
697
|
+
* response is held to {@link limits.responseBytes}.
|
|
698
|
+
*/
|
|
699
|
+
export const run = (source, request) => limitResponse(answer(source, request));
|
|
700
|
+
/**
|
|
701
|
+
* The applied request: `op` and the selected `sessionId` first, then the
|
|
702
|
+
* caller's filters and the filled-in defaults, in a fixed key order so the
|
|
703
|
+
* same query serializes identically live and from a file.
|
|
704
|
+
*/
|
|
705
|
+
const echo = (request, sessionId, applied) => Object.assign({ op: request.op, sessionId }, request, applied, { sessionId });
|
|
706
|
+
const answer = (source, request) => {
|
|
707
|
+
const { op } = request;
|
|
708
|
+
const sessionId = source.session.sessionId;
|
|
709
|
+
const conflicts = source.session.conflicts ?? 0;
|
|
710
|
+
if (conflicts > 0) {
|
|
711
|
+
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 });
|
|
712
|
+
}
|
|
713
|
+
const analysis = new Analysis(source);
|
|
714
|
+
const context = analysis.context();
|
|
715
|
+
const { store } = analysis;
|
|
716
|
+
switch (request.op) {
|
|
717
|
+
case 'summary': {
|
|
718
|
+
const top = request.top ?? limits.top.default;
|
|
719
|
+
return {
|
|
720
|
+
ok: true,
|
|
721
|
+
apiVersion,
|
|
722
|
+
op: 'summary',
|
|
723
|
+
query: { op, sessionId, top },
|
|
724
|
+
...context,
|
|
725
|
+
result: summarize(analysis, top),
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
case 'spans': {
|
|
729
|
+
const status = request.status ?? 'any';
|
|
730
|
+
const sort = request.sort ?? 'start';
|
|
731
|
+
const limit = request.limit ?? limits.spans.default;
|
|
732
|
+
const offset = request.offset ?? 0;
|
|
733
|
+
const needle = request.name?.toLowerCase();
|
|
734
|
+
const from = request.fromMs ?? Number.NEGATIVE_INFINITY;
|
|
735
|
+
const to = request.toMs ?? Number.POSITIVE_INFINITY;
|
|
736
|
+
const matched = [];
|
|
737
|
+
for (const span of store.spans.values()) {
|
|
738
|
+
const item = analysis.item(span);
|
|
739
|
+
if (!matchesStatus(item.status, status))
|
|
740
|
+
continue;
|
|
741
|
+
// Matches the full name, not the shortened one in the response.
|
|
742
|
+
if (needle !== undefined && !span.name.toLowerCase().includes(needle))
|
|
743
|
+
continue;
|
|
744
|
+
if (item.startMs > to || (item.endMs ?? analysis.now) < from)
|
|
745
|
+
continue;
|
|
746
|
+
const measured = item.durationMs ?? item.elapsedLowerBoundMs;
|
|
747
|
+
if (request.minDurationMs !== undefined && measured < request.minDurationMs)
|
|
748
|
+
continue;
|
|
749
|
+
matched.push(item);
|
|
750
|
+
}
|
|
751
|
+
matched.sort(sorters[sort]);
|
|
752
|
+
return {
|
|
753
|
+
ok: true,
|
|
754
|
+
apiVersion,
|
|
755
|
+
op: 'spans',
|
|
756
|
+
query: echo(request, sessionId, { status, sort, limit, offset }),
|
|
757
|
+
...context,
|
|
758
|
+
window: request.fromMs === undefined && request.toMs === undefined
|
|
759
|
+
? null
|
|
760
|
+
: {
|
|
761
|
+
fromMs: request.fromMs ?? null,
|
|
762
|
+
toMs: request.toMs ?? null,
|
|
763
|
+
match: 'overlap',
|
|
764
|
+
inclusive: true,
|
|
765
|
+
timings: 'fullSpan',
|
|
766
|
+
},
|
|
767
|
+
result: page(matched, offset, limit),
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
case 'span': {
|
|
771
|
+
const span = store.spans.get(request.spanId);
|
|
772
|
+
if (span === undefined)
|
|
773
|
+
return spanNotFound(op, request.spanId, analysis);
|
|
774
|
+
const children = request.children ?? limits.children.default;
|
|
775
|
+
const events = request.events ?? limits.events.default;
|
|
776
|
+
return {
|
|
777
|
+
ok: true,
|
|
778
|
+
apiVersion,
|
|
779
|
+
op: 'span',
|
|
780
|
+
query: { op, sessionId, spanId: request.spanId, children, events },
|
|
781
|
+
...context,
|
|
782
|
+
result: detail(analysis, span, children, events),
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
case 'logs': {
|
|
786
|
+
const limit = request.limit ?? limits.logs.default;
|
|
787
|
+
const offset = request.offset ?? 0;
|
|
788
|
+
const scope = request.spanId === undefined ? undefined : (request.scope ?? 'subtree');
|
|
789
|
+
let spans;
|
|
790
|
+
if (request.spanId !== undefined) {
|
|
791
|
+
const span = store.spans.get(request.spanId);
|
|
792
|
+
if (span === undefined)
|
|
793
|
+
return spanNotFound(op, request.spanId, analysis);
|
|
794
|
+
spans = scope === 'span' ? new Set([span.spanId]) : subtree(store, span);
|
|
795
|
+
}
|
|
796
|
+
const minLevel = request.minLevel === undefined ? -1 : logLevels.indexOf(request.minLevel);
|
|
797
|
+
const from = request.fromMs ?? Number.NEGATIVE_INFINITY;
|
|
798
|
+
const to = request.toMs ?? Number.POSITIVE_INFINITY;
|
|
799
|
+
const matched = store.logs
|
|
800
|
+
.filter((log) => (spans === undefined || (log.spanId !== undefined && spans.has(log.spanId))) &&
|
|
801
|
+
(minLevel === -1 ||
|
|
802
|
+
logLevels.indexOf(log.level) >= minLevel) &&
|
|
803
|
+
log.time >= from &&
|
|
804
|
+
log.time <= to)
|
|
805
|
+
// Stable: equal times keep arrival order.
|
|
806
|
+
.sort((a, b) => a.time - b.time);
|
|
807
|
+
const items = matched.slice(offset, offset + limit).map((log) => {
|
|
808
|
+
const { text, truncated } = cut(typeof log.message === 'string' ? log.message : JSON.stringify(log.message), limits.logMessageChars);
|
|
809
|
+
const spanName = log.spanId === undefined ? undefined : store.spans.get(log.spanId)?.name;
|
|
810
|
+
const shortName = spanName === undefined ? undefined : cut(spanName, limits.nameChars);
|
|
811
|
+
return {
|
|
812
|
+
timeMs: ms(log.time),
|
|
813
|
+
level: log.level,
|
|
814
|
+
message: text,
|
|
815
|
+
messageTruncated: truncated,
|
|
816
|
+
spanId: log.spanId ?? null,
|
|
817
|
+
spanName: shortName?.text ?? null,
|
|
818
|
+
spanNameTruncated: shortName?.truncated ?? false,
|
|
819
|
+
fiberId: log.fiberId ?? null,
|
|
820
|
+
annotations: bound(log.annotations),
|
|
821
|
+
};
|
|
822
|
+
});
|
|
823
|
+
return {
|
|
824
|
+
ok: true,
|
|
825
|
+
apiVersion,
|
|
826
|
+
op: 'logs',
|
|
827
|
+
query: echo(request, sessionId, {
|
|
828
|
+
...(scope === undefined ? {} : { scope }),
|
|
829
|
+
limit,
|
|
830
|
+
offset,
|
|
831
|
+
}),
|
|
832
|
+
...context,
|
|
833
|
+
window: request.fromMs === undefined && request.toMs === undefined
|
|
834
|
+
? null
|
|
835
|
+
: {
|
|
836
|
+
fromMs: request.fromMs ?? null,
|
|
837
|
+
toMs: request.toMs ?? null,
|
|
838
|
+
match: 'within',
|
|
839
|
+
inclusive: true,
|
|
840
|
+
},
|
|
841
|
+
result: { ...page(matched, offset, limit), items },
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
};
|
|
846
|
+
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 });
|
|
847
|
+
/** Answers a request against trace-file text: the offline equivalent of a collector query. */
|
|
848
|
+
export const queryFile = (text, file, input) => limitResponse(answerFile(text, file, input));
|
|
849
|
+
const answerFile = (text, file, input) => {
|
|
850
|
+
const decoded = decodeRequest(input);
|
|
851
|
+
if (Result.isFailure(decoded))
|
|
852
|
+
return decoded.failure;
|
|
853
|
+
const request = decoded.success;
|
|
854
|
+
const loaded = fromTraceFile(text, file);
|
|
855
|
+
if (Result.isFailure(loaded))
|
|
856
|
+
return { ...loaded.failure, op: request.op };
|
|
857
|
+
const source = loaded.success;
|
|
858
|
+
if (request.op === 'sessions') {
|
|
859
|
+
return listSessions({ kind: 'file', file }, [source.session], request);
|
|
860
|
+
}
|
|
861
|
+
const saved = source.session.sessionId;
|
|
862
|
+
// A trace re-saved from the browser carries its `loaded:` display prefix.
|
|
863
|
+
if (request.sessionId !== undefined &&
|
|
864
|
+
request.sessionId !== saved &&
|
|
865
|
+
`loaded:${request.sessionId}` !== saved) {
|
|
866
|
+
return sessionNotFound(request.op, request.sessionId, 'is in this file', {
|
|
867
|
+
file,
|
|
868
|
+
fileSessionId: saved,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
return run(source, request);
|
|
872
|
+
};
|
|
873
|
+
/** The live `SessionNotFound` failure, shared by the collector's handlers. */
|
|
874
|
+
export const liveSessionNotFound = (op, sessionId) => sessionNotFound(op, sessionId, 'is known to this collector');
|
|
875
|
+
/** The live failure for a per-session request without a `sessionId`. */
|
|
876
|
+
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.');
|