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,823 @@
1
+ /**
2
+ * The query commands of the `effect-inspect` CLI: `sessions`, `summary`,
3
+ * `spans`, `span`, `logs` and `export`.
4
+ *
5
+ * Every command prints exactly one JSON document on stdout (pretty, or compact
6
+ * with `--json`) and, on failure, one diagnostic on stderr; the exit code
7
+ * names the failure (see {@link exitCodes}). Live queries go through
8
+ * `query/Client.ts`, file queries through `Query.queryFile`, so both answer
9
+ * with the one contract of `query/Query.ts`.
10
+ *
11
+ * The help text here is the primary manual for agents driving the CLI, so it
12
+ * spells out the whole local contract: sources, flags, defaults, JSON shapes,
13
+ * errors and exit codes.
14
+ */
15
+ import { Config, Console, Data, Effect, Option, Result, Stream } from 'effect';
16
+ import { FileSystem } from 'effect/FileSystem';
17
+ import * as Stdio from 'effect/Stdio';
18
+ import { Command, CliError, Flag } from 'effect/unstable/cli';
19
+ import { FetchHttpClient } from 'effect/unstable/http';
20
+ import { defaultPort } from '../collector/Config.js';
21
+ import * as Client from '../query/Client.js';
22
+ import * as Query from '../query/Query.js';
23
+ // ---------------------------------------------------------------------------
24
+ // Output and exit codes
25
+ // ---------------------------------------------------------------------------
26
+ /** Process exit code per outcome. `1` is reserved for unexpected internal errors. */
27
+ export const exitCodes = {
28
+ InvalidRequest: 2,
29
+ SessionNotFound: 3,
30
+ SpanNotFound: 4,
31
+ SessionConflict: 5,
32
+ ResponseTooLarge: 6,
33
+ TraceFileError: 7,
34
+ CollectorUnavailable: 8,
35
+ CollectorError: 9,
36
+ OutputError: 10,
37
+ };
38
+ /** Longest `--timeout-ms` accepted: ten minutes. */
39
+ const maxTimeoutMs = 600_000;
40
+ /** A handled failure: its JSON is already printed, only the exit code is left. */
41
+ class CliExit extends Data.TaggedError('CliExit') {
42
+ }
43
+ const write = (text, to) => Effect.gen(function* () {
44
+ const stdio = yield* Stdio.Stdio;
45
+ yield* Stream.run(Stream.make(text), to === 'stdout' ? stdio.stdout() : stdio.stderr());
46
+ }).pipe(Effect.ignore);
47
+ const encoder = new TextEncoder();
48
+ /** Pretty JSON by default; one compact line with `--json`. */
49
+ const render = (response, json) => `${JSON.stringify(response, null, json ? undefined : 2)}\n`;
50
+ /**
51
+ * The exact stdout text for `response`, held to `Query.limits.responseBytes`
52
+ * as emitted: after formatting, trailing newline included. The query layer
53
+ * bounds compact JSON only, so indentation (or the newline) can still push a
54
+ * page over; that becomes a small fixed-shape `ResponseTooLarge` instead.
55
+ */
56
+ export const renderBounded = (response, json) => {
57
+ const text = render(response, json);
58
+ const bytes = encoder.encode(text).length;
59
+ if (bytes <= Query.limits.responseBytes)
60
+ return { response, text };
61
+ const bounded = Query.failure(response.op, 'ResponseTooLarge', `The ${json ? 'compact' : 'pretty-printed'} JSON output would be ${bytes} bytes on stdout, over the ${Query.limits.responseBytes}-byte limit.`, json
62
+ ? 'Request fewer items (--limit, --top, --children, --events) or narrow the filters. Identifiers are never shortened to fit.'
63
+ : 'Add --json (compact, no indentation) or request fewer items (--limit, --top, --children, --events) or narrow the filters. Identifiers are never shortened to fit.', {
64
+ bytes,
65
+ limitBytes: Query.limits.responseBytes,
66
+ originalOutcome: response.ok ? 'ok' : response.error._tag,
67
+ output: json ? 'compact' : 'pretty',
68
+ });
69
+ return { response: bounded, text: render(bounded, json) };
70
+ };
71
+ /** Prints `response` and, for a failure, a stderr diagnostic and its exit code. */
72
+ const emit = Effect.fnUntraced(function* (unbounded, json) {
73
+ const { response, text } = renderBounded(unbounded, json);
74
+ yield* write(text, 'stdout');
75
+ if (response.ok)
76
+ return;
77
+ const code = exitCodes[response.error._tag];
78
+ yield* write(`effect-inspect${response.op === null ? '' : ` ${response.op}`}: ${response.error._tag} (exit ${code}): ${response.error.message}\nhint: ${response.error.hint}\n`, 'stderr');
79
+ return yield* new CliExit({ code });
80
+ });
81
+ const invalid = (op, message, hint) => Query.failure(op, 'InvalidRequest', message, hint);
82
+ /** Keeps only the fields that were given, so the echo shows what was asked. */
83
+ const given = (fields) => {
84
+ const out = {};
85
+ for (const [key, value] of Object.entries(fields)) {
86
+ if (Option.isSome(value))
87
+ out[key] = value.value;
88
+ }
89
+ return out;
90
+ };
91
+ /** Collector URL: `--url`, else `http://localhost:$EFFECT_INSPECT_PORT`, else the default port. */
92
+ const liveTarget = (op, flags) => Effect.gen(function* () {
93
+ const timeoutMs = Option.getOrElse(flags.timeoutMs, () => Client.defaultTimeoutMs);
94
+ if (timeoutMs < 1 || timeoutMs > maxTimeoutMs) {
95
+ return yield* Effect.fail(invalid(op, `--timeout-ms must be an integer from 1 to ${maxTimeoutMs}.`, `Omit it for the ${Client.defaultTimeoutMs} ms default.`));
96
+ }
97
+ let url;
98
+ if (Option.isSome(flags.url))
99
+ url = flags.url.value;
100
+ else {
101
+ const port = yield* Effect.result(Config.Port('EFFECT_INSPECT_PORT').pipe(Config.withDefault(defaultPort)));
102
+ if (Result.isFailure(port)) {
103
+ return yield* Effect.fail(invalid(op, 'EFFECT_INSPECT_PORT is set but is not a valid port (1-65535).', 'Fix or unset EFFECT_INSPECT_PORT, or pass --url http://HOST:PORT.'));
104
+ }
105
+ url = `http://localhost:${port.success}`;
106
+ }
107
+ const parsed = URL.canParse(url) ? new URL(url) : undefined;
108
+ if (parsed === undefined || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
109
+ return yield* Effect.fail(invalid(op, '--url must be an absolute http:// or https:// URL.', `Use the collector's HTTP address, e.g. http://localhost:${defaultPort} (the same port programs reach at ws://).`));
110
+ }
111
+ return { url, timeoutMs };
112
+ });
113
+ const readTrace = (op, path) => Effect.gen(function* () {
114
+ const fs = yield* FileSystem;
115
+ return yield* fs.readFileString(path);
116
+ }).pipe(Effect.mapError((error) => Query.failure(op, 'TraceFileError', `The file could not be read: ${error.message}`, 'Check that --file names an existing, readable .eitrace file (written by `effect-inspect export` or the web UI save button).', { file: path })));
117
+ /**
118
+ * Answers one query from exactly one source: the collector for `--session`,
119
+ * or a saved file for `--file` (with `--session` as an assertion).
120
+ */
121
+ const answer = (op, flags, fields) => Effect.gen(function* () {
122
+ const session = Option.getOrUndefined(flags.session);
123
+ if (Option.isSome(flags.file)) {
124
+ if (Option.isSome(flags.url) || Option.isSome(flags.timeoutMs)) {
125
+ return invalid(op, '--url and --timeout-ms apply only to live queries and cannot be combined with --file.', 'Drop --url/--timeout-ms to query the file, or drop --file to query the collector.');
126
+ }
127
+ const request = { op, ...(session === undefined ? {} : { sessionId: session }), ...fields };
128
+ const decoded = Query.decodeRequest(request);
129
+ if (Result.isFailure(decoded))
130
+ return decoded.failure;
131
+ const text = yield* readTrace(op, flags.file.value);
132
+ return Query.queryFile(text, flags.file.value, request);
133
+ }
134
+ if (session === undefined && op !== 'sessions') {
135
+ return invalid(op, 'Name the source: --session ID for a live collector query, or --file PATH for a saved trace. The newest session is never assumed.', 'Pass the exact EFFECT_INSPECT_SESSION_ID the program was launched with; run `effect-inspect sessions` only if you do not know it.');
136
+ }
137
+ const request = { op, ...(session === undefined ? {} : { sessionId: session }), ...fields };
138
+ const decoded = Query.decodeRequest(request);
139
+ if (Result.isFailure(decoded))
140
+ return decoded.failure;
141
+ const target = yield* liveTarget(op, flags);
142
+ return yield* Client.query(target, request).pipe(Effect.provide(FetchHttpClient.layer));
143
+ }).pipe(Effect.catch((failure) => Effect.succeed(failure)));
144
+ // ---------------------------------------------------------------------------
145
+ // Flags
146
+ // ---------------------------------------------------------------------------
147
+ const json = Flag.Boolean('json').pipe(Flag.withDefault(false), Flag.withDescription('Print the JSON compact on one line (recommended for programs). Without it the same JSON is pretty-printed.'));
148
+ const session = Flag.String('session').pipe(Flag.optional, Flag.withDescription('Exact session ID. Live: required, selects that session on the collector. With --file: optional assertion that the file holds this ID.'));
149
+ const file = Flag.String('file').pipe(Flag.optional, Flag.withDescription('Path of a saved .eitrace to query offline instead of the collector. No collector is contacted.'));
150
+ const url = Flag.String('url').pipe(Flag.optional, Flag.withDescription(`Live only. Collector base URL. Default http://localhost:$EFFECT_INSPECT_PORT, else http://localhost:${defaultPort}.`));
151
+ const timeoutMs = Flag.Int('timeout-ms').pipe(Flag.optional, Flag.withDescription(`Live only. Milliseconds to wait for the whole answer, 1-${maxTimeoutMs} (default ${Client.defaultTimeoutMs}); then CollectorUnavailable.`));
152
+ const page = (max, fallback, what) => ({
153
+ limit: Flag.Int('limit').pipe(Flag.optional, Flag.withDescription(`${what} per page, 1-${max} (default ${fallback}).`)),
154
+ offset: Flag.Int('offset').pipe(Flag.optional, Flag.withDescription('Items to skip, >= 0 (default 0). Use result.nextOffset from the previous page.')),
155
+ });
156
+ const window = (what) => ({
157
+ fromMs: Flag.Finite('from-ms').pipe(Flag.optional, Flag.withDescription(`Window start in session ms, inclusive (${what}). Negative values need = : --from-ms=-5.`)),
158
+ toMs: Flag.Finite('to-ms').pipe(Flag.optional, Flag.withDescription(`Window end in session ms, inclusive; must be >= --from-ms.`)),
159
+ });
160
+ // ---------------------------------------------------------------------------
161
+ // Help text
162
+ // ---------------------------------------------------------------------------
163
+ /** Indents continuation lines under the help formatter's DESCRIPTION heading. */
164
+ const text = (body) => body.trim().split('\n').join('\n ');
165
+ const sourceRules = `
166
+ SOURCE (exactly one; never implicit)
167
+ --session ID Live. Asks the collector for the session with exactly this ID.
168
+ IDs are case-sensitive and never completed, guessed or replaced by
169
+ the newest session. The ID is the EFFECT_INSPECT_SESSION_ID (or the
170
+ Inspect.layer sessionId option) the program was launched with.
171
+ --file PATH Offline. Reads a saved .eitrace (from \`export\` or the web UI).
172
+ No collector is contacted; --url and --timeout-ms are rejected.
173
+ --session may be added: then the file must hold exactly that ID
174
+ (a web-UI re-save's "loaded:" prefix is accepted), else SessionNotFound.
175
+ Neither InvalidRequest (exit 2), even when only one session exists.
176
+
177
+ COLLECTOR ADDRESS (live only)
178
+ --url URL, else http://localhost:$EFFECT_INSPECT_PORT, else http://localhost:${defaultPort}.
179
+ It is the same port \`effect-inspect start\` listens on. --timeout-ms (default
180
+ ${Client.defaultTimeoutMs}) bounds connecting plus reading the whole answer; when it passes,
181
+ or nothing listens, the result is CollectorUnavailable (exit 8).`;
182
+ const context = `
183
+ CONTEXT FIELDS (every successful per-session response)
184
+ Top-level siblings of result, never inside it (.completeness, not
185
+ .result.completeness). result's own fields are listed above (RESULT FIELDS /
186
+ SPAN ITEM FIELDS); root \`effect-inspect --help\` has the JSON SHAPE overview.
187
+ notices summary only, top level: read it first. { code, message } facts easy
188
+ to miss (open spans, eviction, sampling gaps); run \`summary\` before
189
+ drilling down with other commands.
190
+ query The request as applied, defaults filled in. Check it to confirm
191
+ which filters were used.
192
+ source kind "live"|"file", file (path or null), sessionId (exact), program,
193
+ pid, runtime, active (live: program still connected, so more data
194
+ may arrive and a repeated query can differ), startedAtEpochMillis,
195
+ endedAtEpochMillis (connection closed; not proof the program
196
+ succeeded), snapshotAtEpochMillis (live: query time; file: save time).
197
+ programTruncated/runtimeTruncated mark text cut to 200 characters.
198
+ time unit "ms"; reference "sessionStart": every ...Ms field is milliseconds
199
+ since the session's clock origin (when its inspect client started),
200
+ microsecond resolution. Wall clock = startedAtEpochMillis + ms. Values
201
+ can be negative (work that began before the client). observedFromMs /
202
+ observedUntilMs: earliest / latest retained timestamp, null when none.
203
+ termination state "active" (still connected at snapshot time; open spans may still
204
+ end), "ended" (the collector recorded a disconnect) or "unknown" (no
205
+ end time on record). lastObservedMs (= observedUntilMs), endedAtMs
206
+ (the disconnect in session ms, from the collector's wall clock, so
207
+ approximate; null unless ended), unobservedTailMs (endedAtMs -
208
+ lastObservedMs: time before the disconnect with nothing retained).
209
+ The protocol has no end-of-session message: a crash, a kill, a clean
210
+ exit and a dropped connection look the same, so open spans at the end
211
+ do not by themselves establish a crash.
212
+ completeness status: "noLossRecorded" (collector counters known, all loss/gap
213
+ counters 0 - still not proof nothing is missing), "lossRecorded" (some
214
+ counter > 0: evidence is partial), or "unknown" (source never kept
215
+ collector counters: browser save or older file). Counters:
216
+ collectorDroppedMessages and collectorSkippedLines (null = unknown),
217
+ clientDroppedMessages, fileTruncatedLines, spansMissingStart,
218
+ spansOutOfOrder, spansMissingParent, openSpans, retainedMessages,
219
+ messagesObserved (null = unknown; unchanged between two responses
220
+ means the data did not change).
221
+ conflict count (runs refused for reusing this ID; null = unknown) and detection:
222
+ "enforced" (the collector refuses reused IDs), "unavailable" (older
223
+ client: a reuse would have merged silently, so count 0 proves
224
+ nothing), "unknown" (file without collector metadata).
225
+ A session with count > 0 is never answered: see SessionConflict.`;
226
+ const spanItem = `
227
+ SPAN ITEM FIELDS
228
+ spanId, traceId Exact IDs, never shortened; pass spanId to \`span\` / \`logs --span\`.
229
+ name, nameTruncated Span name, cut to 200 characters.
230
+ kind internal | server | client | producer | consumer.
231
+ parentSpanId Local parent span ID, or null (root or remote parent).
232
+ status ok | error (typed failure, Fail) | defect (Die) |
233
+ interrupted | open (no end recorded).
234
+ startMs, endMs Session ms; endMs null while open.
235
+ durationMs endMs - startMs: elapsed wall time. null when open.
236
+ childCoveredMs Union of recorded direct-child intervals clipped to the span
237
+ (overlapping children count once). null when open.
238
+ outsideChildrenMs durationMs - childCoveredMs: elapsed time no recorded child
239
+ covers. NOT CPU time and not proof of missing instrumentation.
240
+ elapsedLowerBoundMs Open spans only: observedUntilMs - startMs, how long it had been
241
+ open at the last observation. An open span may still be running
242
+ or its end may be lost; it is not a deadlock verdict.
243
+ childCount, openChildCount, eventCount, logCount
244
+ error null, or { kind: "Fail"|"Die"|"Interrupt", message (<= 500
245
+ characters), messageTruncated }.`;
246
+ const timing = `
247
+ READING TIMINGS
248
+ Durations are observations, not verdicts. A long span may be waiting, doing I/O,
249
+ retrying, or running code without spans; outsideChildrenMs only says no recorded
250
+ child covered that time. Neither field is CPU time, and nothing here labels an
251
+ operation slow: there are no built-in budgets or baselines. State any threshold
252
+ you apply yourself (e.g. --min-duration-ms is echoed in query).`;
253
+ const outputRules = (op, emptyResults = true) => `
254
+ OUTPUT
255
+ stdout One JSON document: pretty-printed (2-space indent), or compact on one line
256
+ with --json, followed by a newline.
257
+ Success: { "ok": true, "apiVersion": 1, "op": "${op}", "query", ..., "result" }.
258
+ Failure: { "ok": false, "apiVersion": 1, "op", "error": { "_tag", "message",
259
+ "hint", ...details } }.
260
+ The complete stdout, as printed in the chosen mode and including the final
261
+ newline, is always at most 1048576 UTF-8 bytes. Pretty output is larger than
262
+ compact, so a page can fit with --json but not without it: that case is
263
+ ResponseTooLarge with error.output "pretty" (exit 6); add --json or ask for
264
+ fewer items.
265
+ stderr Empty on success. On failure one line "effect-inspect ${op}: TAG (exit N): message"
266
+ and a "hint:" line.${emptyResults ? '\n An empty result is a success: ok true, result.total 0, exit 0.' : ''}
267
+ Error messages from the query engine name request fields: sessionId = --session,
268
+ spanId = --span, fromMs/toMs = --from-ms/--to-ms, minDurationMs = --min-duration-ms,
269
+ minLevel = --min-level; limit, offset, top, children, events, status, sort, name and
270
+ scope match their flags.`;
271
+ const exitTable = (tags) => `
272
+ EXIT CODES AND ERRORS (error._tag)
273
+ 0 ok, including empty results
274
+ 1 internal error (bug; details on stderr)
275
+ ${tags.map((tag) => ` ${String(exitCodes[tag]).padEnd(3)} ${errorHelp[tag]}`).join('\n')}`;
276
+ const errorHelp = {
277
+ InvalidRequest: 'InvalidRequest: unknown, missing, conflicting, malformed or out-of-range flag. Nothing\n was queried. Fix the flags (see above).',
278
+ SessionNotFound: 'SessionNotFound: no session with exactly error.sessionId on this collector (or in\n this file; then error.fileSessionId names what it holds). Nothing was substituted.\n Check the ID you launched with, that the program uses Inspect.layer() pointed at\n this collector and has started, --url/EFFECT_INSPECT_PORT; or run `sessions`.',
279
+ SpanNotFound: 'SpanNotFound: the session has no retained span error.spanId (wrong ID, evicted, or\n never received; error.completeness says whether loss was recorded). Use `spans`.',
280
+ SessionConflict: 'SessionConflict: error.conflicts other runs announced this ID, so no data can be\n attributed to one run. Relaunch with a new unique EFFECT_INSPECT_SESSION_ID.',
281
+ ResponseTooLarge: 'ResponseTooLarge: the answer would exceed 1048576 bytes (error.limitBytes). Without\n error.output, the query itself was too large as compact JSON (live: HTTP 413) and\n error.bytes is that size; with error.output "pretty" or "compact", error.bytes is\n the size stdout would have had in that mode. error.originalOutcome is what it\n would have been. Add --json if output is "pretty", lower --limit/--top/\n --children/--events or narrow the filters; IDs are never shortened to fit.',
282
+ TraceFileError: 'TraceFileError: --file could not be read, is empty, is not a trace, is from a newer\n format, or is corrupt (error.file). Gzip is not supported.',
283
+ CollectorUnavailable: 'CollectorUnavailable: nothing answered at error.url within --timeout-ms. Start\n `effect-inspect start`, fix --url/EFFECT_INSPECT_PORT, raise --timeout-ms, or use --file.',
284
+ CollectorError: 'CollectorError: something answered at error.url but not this query API (an older\n collector or another service). Upgrade/restart the collector or fix the URL.',
285
+ OutputError: 'OutputError: --out could not be written (error.file): it already exists (pass\n --force to replace it) or its directory is missing or not writable.',
286
+ };
287
+ const sessionErrors = [
288
+ 'InvalidRequest',
289
+ 'SessionNotFound',
290
+ 'SessionConflict',
291
+ 'ResponseTooLarge',
292
+ 'TraceFileError',
293
+ 'CollectorUnavailable',
294
+ 'CollectorError',
295
+ ];
296
+ const spanErrors = [
297
+ 'InvalidRequest',
298
+ 'SessionNotFound',
299
+ 'SpanNotFound',
300
+ 'SessionConflict',
301
+ 'ResponseTooLarge',
302
+ 'TraceFileError',
303
+ 'CollectorUnavailable',
304
+ 'CollectorError',
305
+ ];
306
+ const livePaging = `
307
+ Live sessions that are still active (source.active true) can change between two
308
+ calls, so offsets may shift; compare completeness.messagesObserved, wait until
309
+ active is false, or export and page through the file for a stable walk.`;
310
+ const pagingRules = (max, fallback, order, consistency = livePaging) => `
311
+ PAGING
312
+ result is a page: { total, offset, limit, nextOffset, items }. total counts every
313
+ match; nextOffset is the --offset of the next page, or null on the last page.
314
+ --limit 1-${max} (default ${fallback}). Order: ${order}.${consistency}`;
315
+ // ---------------------------------------------------------------------------
316
+ // Commands
317
+ // ---------------------------------------------------------------------------
318
+ const sessionsCommand = Command.make('sessions', {
319
+ file,
320
+ url,
321
+ timeoutMs,
322
+ ...page(Query.limits.sessions.max, Query.limits.sessions.default, 'Sessions'),
323
+ json,
324
+ }, (flags) => Effect.flatMap(answer('sessions', { ...flags, session: Option.none() }, given({ limit: flags.limit, offset: flags.offset })), (response) => emit(response, flags.json))).pipe(Command.withShortDescription('Discovery only: list sessions when you do not know the ID'), Command.withDescription(text(`
325
+ List sessions on the collector (newest start first, ties by ID), or the single session
326
+ in a --file. Use it only to discover an ID you did not choose: when you launched the
327
+ program with EFFECT_INSPECT_SESSION_ID, query that ID directly with \`summary\`.
328
+
329
+ SOURCE
330
+ Live by default (no --session: this command takes none). --file PATH lists the file's
331
+ one session instead; --url/--timeout-ms are then rejected.
332
+ Collector address: --url, else http://localhost:$EFFECT_INSPECT_PORT, else
333
+ http://localhost:${defaultPort}. --timeout-ms bounds the whole call (default ${Client.defaultTimeoutMs}).
334
+
335
+ RESULT
336
+ { "ok": true, "apiVersion": 1, "op": "sessions",
337
+ "query": { "op": "sessions", "limit": 50, "offset": 0 },
338
+ "source": { "kind": "live", "file": null },
339
+ "result": { "total": 1, "offset": 0, "limit": 50, "nextOffset": null, "items": [
340
+ { "sessionId": "failing-run-001", "program": "example:failing",
341
+ "programTruncated": false, "pid": 58569, "runtime": "bun 1.4.2",
342
+ "runtimeTruncated": false, "active": false,
343
+ "startedAtEpochMillis": 1790352511476, "endedAtEpochMillis": 1790352511719,
344
+ "conflicts": null } ] } }
345
+ active The program is still connected (more data may arrive).
346
+ endedAtEpochMillis When the connection closed (null while active); not success.
347
+ conflicts Refused reuses of the ID (queries for it fail with SessionConflict);
348
+ null = none recorded, which for an older client proves nothing.
349
+ An empty collector gives total 0 and items [] with exit 0.
350
+ ${pagingRules(Query.limits.sessions.max, Query.limits.sessions.default, 'newest startedAtEpochMillis first', `
351
+ Each call lists the collector's sessions at that moment: sessions that start
352
+ between two calls shift offsets. Compare result.total between pages, or use a
353
+ --limit large enough for one page.`)}
354
+ ${outputRules('sessions')}
355
+ ${exitTable(['InvalidRequest', 'ResponseTooLarge', 'TraceFileError', 'CollectorUnavailable', 'CollectorError'])}
356
+ `)), Command.withExamples([
357
+ { command: 'effect-inspect sessions --json', description: 'Sessions on the local collector' },
358
+ {
359
+ command: 'effect-inspect sessions --file failing-run-001.eitrace --json',
360
+ description: 'The session a saved file holds',
361
+ },
362
+ ]));
363
+ const summaryCommand = Command.make('summary', {
364
+ session,
365
+ file,
366
+ url,
367
+ timeoutMs,
368
+ top: Flag.Int('top').pipe(Flag.optional, Flag.withDescription(`Items per ranked list, 1-${Query.limits.top.max} (default ${Query.limits.top.default}).`)),
369
+ json,
370
+ }, (flags) => Effect.flatMap(answer('summary', flags, given({ top: flags.top })), (response) => emit(response, flags.json))).pipe(Command.withShortDescription('Step 1: counts, failures, longest spans and completeness of one run'), Command.withDescription(text(`
371
+ Overview of one session: notices, span counts by status, failures, unfinished spans
372
+ and where they were last recorded, the longest completed spans, the spans
373
+ with the most time outside recorded children, still-open spans, per-name totals, log
374
+ counts by level and memory samples, plus how complete the evidence is. Start every
375
+ investigation here, then drill down with \`spans\`, \`span\` and \`logs\`.
376
+ ${sourceRules}
377
+
378
+ RESULT (abbreviated; lists hold at most --top items)
379
+ { "ok": true, "apiVersion": 1, "op": "summary",
380
+ "query": { "op": "summary", "sessionId": "failing-run-001", "top": 5 },
381
+ "notices": [],
382
+ "source": { "kind": "live", "sessionId": "failing-run-001", "active": false, ... },
383
+ "time": { "unit": "ms", "reference": "sessionStart",
384
+ "observedFromMs": -1.348, "observedUntilMs": 240.867 },
385
+ "termination": { "state": "ended", "lastObservedMs": 240.867, "endedAtMs": 243,
386
+ "unobservedTailMs": 2.133 },
387
+ "completeness": { "status": "noLossRecorded", "openSpans": 0, ... },
388
+ "conflict": { "count": 0, "detection": "enforced" },
389
+ "result": {
390
+ "spans": { "total": 11, "ok": 6, "error": 3, "defect": 1, "interrupted": 1, "open": 0 },
391
+ "spanEvents": 3,
392
+ "logs": { "total": 4, "byLevel": { "Info": 3, "Warn": 1 } },
393
+ "memory": { "samples": 2, "peakHeapUsedBytes": 7158493, "peakRssBytes": 61489152,
394
+ "lastHeapUsedBytes": 7158493 },
395
+ "failures": { "total": 4, "items": [ SPAN ITEM, ... ] },
396
+ "unfinished": { "open": 0, "innermost": { "total": 0, "items": [] } },
397
+ "longest": [ SPAN ITEM, ... ],
398
+ "largestOutsideChildren": [ SPAN ITEM, ... ],
399
+ "longestOpen": [ SPAN ITEM, ... ],
400
+ "names": { "total": 11, "items": [ { "name": "failures", "nameTruncated": false,
401
+ "count": 1, "completed": 1, "open": 0, "failed": 0, "totalDurationMs": 241.993,
402
+ "maxDurationMs": 241.993, "totalOutsideChildrenMs": 0.658 }, ... ] } } }
403
+
404
+ RESULT FIELDS
405
+ notices Top level, before result: { code, message } facts easy to miss.
406
+ openSpans (spans without a recorded end, the termination state
407
+ and the last recorded position), rankingsCompletedOnly (longest and
408
+ largestOutsideChildren skip open spans), collectorEvicted (oldest
409
+ messages evicted at capacity: data before observedFromMs is
410
+ missing), memorySamplingGap (the largest gap between memory
411
+ samples exceeds 10x the median). Messages state facts;
412
+ explanations are possibilities.
413
+ spans Counts by status. failures lists error and defect spans (not
414
+ interruptions), earliest start first; failures.total counts all.
415
+ unfinished open: spans without a recorded end. innermost: open spans with no
416
+ open child - the last recorded position on each open chain, not a
417
+ cause - largest elapsedLowerBoundMs first. Each is a SPAN ITEM plus
418
+ openAncestors: { items: [ { spanId, name, nameTruncated, status,
419
+ startMs, durationMs, elapsedLowerBoundMs } ], truncated }:
420
+ contiguous open ancestors, root-most first, at most 32 nearest;
421
+ truncated marks more above. durationMs is null (no end);
422
+ elapsedLowerBoundMs is observedUntilMs - startMs.
423
+ longest Completed spans only, largest durationMs first.
424
+ largestOutsideChildren Completed spans only, largest outsideChildrenMs first.
425
+ longestOpen Open spans, largest elapsedLowerBoundMs first.
426
+ names Groups by full span name, largest totalDurationMs first. Sums cover
427
+ completed spans; nested and concurrent spans overlap, so totals can
428
+ exceed the run's wall time. failed counts every failure incl.
429
+ interruptions.
430
+ logs.byLevel Only levels that occur.
431
+ memory Process-wide samples (all work in the process), or null without
432
+ samples: { samples, peakHeapUsedBytes, peakHeapAtMs, peakRssBytes,
433
+ lastHeapUsedBytes, firstSampleMs, lastSampleMs, medianIntervalMs,
434
+ maxGapMs, maxGapFromMs, maxGapToMs, spansActiveAtPeak }. Periodic
435
+ samples miss peaks between them; maxGapMs is the longest stretch
436
+ with no sample. spansActiveAtPeak: { total, items: [ { spanId,
437
+ name, nameTruncated, status, startMs, durationMs } ] }, the
438
+ innermost spans active at peakHeapAtMs - active at that time only,
439
+ not shown to be what the heap in use belongs to.
440
+ ${spanItem}
441
+ ${context}
442
+ ${timing}
443
+ ${outputRules('summary')}
444
+ ${exitTable(sessionErrors)}
445
+ `)), Command.withExamples([
446
+ {
447
+ command: 'effect-inspect summary --session failing-run-001 --json',
448
+ description: 'Live: the run launched with EFFECT_INSPECT_SESSION_ID=failing-run-001',
449
+ },
450
+ {
451
+ command: 'effect-inspect summary --file failing-run-001.eitrace --session failing-run-001 --json',
452
+ description: 'Offline, asserting the file holds that session',
453
+ },
454
+ ]));
455
+ const spansCommand = Command.make('spans', {
456
+ session,
457
+ file,
458
+ url,
459
+ timeoutMs,
460
+ status: Flag.Literals('status', ['any', 'failed', ...Query.spanStatuses]).pipe(Flag.optional, Flag.withDescription('Status filter (default any). failed = error, defect and interrupted.')),
461
+ name: Flag.String('name').pipe(Flag.optional, Flag.withDescription(`Case-insensitive substring of the full span name (at most ${Query.limits.requestTextChars} characters).`)),
462
+ minDurationMs: Flag.Finite('min-duration-ms').pipe(Flag.optional, Flag.withDescription('Only spans with durationMs (open: elapsedLowerBoundMs) >= this many ms, >= 0.')),
463
+ ...window('selects spans overlapping the window'),
464
+ sort: Flag.Literals('sort', ['start', 'duration', 'outsideChildren']).pipe(Flag.optional, Flag.withDescription('Order (default start). See ORDER below.')),
465
+ ...page(Query.limits.spans.max, Query.limits.spans.default, 'Spans'),
466
+ json,
467
+ }, (flags) => Effect.flatMap(answer('spans', flags, given({
468
+ status: flags.status,
469
+ name: flags.name,
470
+ minDurationMs: flags.minDurationMs,
471
+ fromMs: flags.fromMs,
472
+ toMs: flags.toMs,
473
+ sort: flags.sort,
474
+ limit: flags.limit,
475
+ offset: flags.offset,
476
+ })), (response) => emit(response, flags.json))).pipe(Command.withShortDescription('Step 2: filter, rank and page the spans of one run'), Command.withDescription(text(`
477
+ A filtered, sorted page of one session's spans. Use --status failed to find failures,
478
+ --sort duration or --sort outsideChildren to rank elapsed time, --name to follow one
479
+ operation, and --from-ms/--to-ms to look at a time range. Copy a spanId into \`span\`
480
+ or \`logs --span\`.
481
+ ${sourceRules}
482
+
483
+ FILTERS (all combine with AND)
484
+ --status any | failed | ok | error | defect | interrupted | open (default any).
485
+ error = typed failure (Fail), defect = Die, interrupted = Interrupt,
486
+ failed = any of those three, open = no end recorded.
487
+ --name TEXT Case-insensitive substring of the full (untruncated) name.
488
+ --min-duration-ms Minimum durationMs, or elapsedLowerBoundMs for open spans.
489
+ --from-ms/--to-ms Spans whose [startMs, endMs] (open spans: [startMs,
490
+ observedUntilMs]) overlaps the window, bounds inclusive, are
491
+ selected. Their timings are for the whole span, NOT clipped to the
492
+ window. The response's "window" field states this:
493
+ { "fromMs", "toMs", "match": "overlap", "inclusive": true,
494
+ "timings": "fullSpan" }, or null without a window. Either bound
495
+ may be given alone. Negative values: --from-ms=-5.
496
+
497
+ ORDER (--sort)
498
+ start startMs ascending (default).
499
+ duration durationMs descending. Open spans are interleaved by
500
+ elapsedLowerBoundMs: their true duration is at least that.
501
+ outsideChildren outsideChildrenMs descending. Open spans are interleaved by the
502
+ time so far no recorded child covered (open children count as
503
+ covering up to observedUntilMs): a lower bound, not shown in the
504
+ item (their outsideChildrenMs is null).
505
+ Ties: startMs, then spanId.
506
+ ${pagingRules(Query.limits.spans.max, Query.limits.spans.default, 'as --sort')}
507
+
508
+ RESULT (abbreviated)
509
+ { "ok": true, "apiVersion": 1, "op": "spans",
510
+ "query": { "op": "spans", "sessionId": "failing-run-001", "status": "failed",
511
+ "sort": "start", "limit": 20, "offset": 0 },
512
+ "source": { ... }, "time": { ... }, "termination": { ... },
513
+ "completeness": { ... }, "conflict": { ... },
514
+ "window": null,
515
+ "result": { "total": 5, "offset": 0, "limit": 20, "nextOffset": null, "items": [
516
+ { "spanId": "0a40c31fbf88b7db", "traceId": "9d502fd678d8f15c0328b182dc1e3509",
517
+ "name": "charge.card", "nameTruncated": false, "kind": "internal",
518
+ "parentSpanId": "99dd044045282fe6", "status": "error",
519
+ "startMs": -0.671, "endMs": 20.775, "durationMs": 21.446,
520
+ "childCoveredMs": 0, "outsideChildrenMs": 21.446, "elapsedLowerBoundMs": null,
521
+ "childCount": 0, "openChildCount": 0, "eventCount": 1, "logCount": 1,
522
+ "error": { "kind": "Fail",
523
+ "message": "PaymentDeclined: card **** 4242 was declined\\n at ...",
524
+ "messageTruncated": false } }, ... ] } }
525
+ ${spanItem}
526
+ ${context}
527
+ ${timing}
528
+ ${outputRules('spans')}
529
+ ${exitTable(sessionErrors)}
530
+ `)), Command.withExamples([
531
+ {
532
+ command: 'effect-inspect spans --session failing-run-001 --status failed --json',
533
+ description: 'Every failed span, earliest first',
534
+ },
535
+ {
536
+ command: 'effect-inspect spans --session failing-run-001 --sort outsideChildren --limit 5 --json',
537
+ description: 'Five spans ranked by elapsed time outside recorded children (open spans by a lower bound)',
538
+ },
539
+ {
540
+ command: 'effect-inspect spans --session failing-run-001 --from-ms=0 --to-ms 50 --sort duration --json',
541
+ description: 'Spans overlapping the first 50 ms, longest first',
542
+ },
543
+ {
544
+ command: 'effect-inspect spans --file failing-run-001.eitrace --name fetch --json',
545
+ description: 'Offline: spans whose name contains "fetch"',
546
+ },
547
+ ]));
548
+ const spanCommand = Command.make('span', {
549
+ session,
550
+ file,
551
+ url,
552
+ timeoutMs,
553
+ span: Flag.String('span').pipe(Flag.withDescription('Required. Exact spanId (from summary, spans or logs).')),
554
+ children: Flag.Int('children').pipe(Flag.optional, Flag.withDescription(`Children listed, 1-${Query.limits.children.max} (default ${Query.limits.children.default}).`)),
555
+ events: Flag.Int('events').pipe(Flag.optional, Flag.withDescription(`Span events listed, 1-${Query.limits.events.max} (default ${Query.limits.events.default}).`)),
556
+ json,
557
+ }, (flags) => Effect.flatMap(answer('span', flags, given({ spanId: Option.some(flags.span), children: flags.children, events: flags.events })), (response) => emit(response, flags.json))).pipe(Command.withShortDescription('Step 3: one span with error, attributes, ancestry, children, events'), Command.withDescription(text(`
558
+ Everything retained about one span: its timing and outcome, error message and stack,
559
+ attributes, where it sits (parent and ancestry up to the root), its first children and
560
+ its span events. Use it after \`summary\` or \`spans\` gave you a spanId.
561
+ ${sourceRules}
562
+
563
+ RESULT (abbreviated): a SPAN ITEM plus the fields below
564
+ { "ok": true, "apiVersion": 1, "op": "span",
565
+ "query": { "op": "span", "sessionId": "failing-run-001",
566
+ "spanId": "0a40c31fbf88b7db", "children": 20, "events": 20 },
567
+ "source": { ... }, "time": { ... }, "termination": { ... },
568
+ "completeness": { ... }, "conflict": { ... },
569
+ "result": { "spanId": "0a40c31fbf88b7db", "name": "charge.card", "status": "error",
570
+ ...other SPAN ITEM fields...,
571
+ "attributes": { "entries": [], "omittedKeys": 0 },
572
+ "stack": "Error\\n at <anonymous> (.../examples/failing.ts:36:21)\\n ...",
573
+ "stackTruncated": false,
574
+ "parent": { "kind": "local", "spanId": "99dd044045282fe6", "retained": true },
575
+ "ancestry": { "items": [
576
+ { "spanId": "3a4801517020bae9", "name": "failures", "nameTruncated": false,
577
+ "status": "ok", "startMs": -1.126, "durationMs": 241.993 },
578
+ { "spanId": "99dd044045282fe6", "name": "typed-error", "nameTruncated": false,
579
+ "status": "ok", "startMs": -0.702, "durationMs": 21.949 } ],
580
+ "truncated": false },
581
+ "children": { "total": 0, "items": [] },
582
+ "events": { "total": 1, "items": [ { "name": "charging card **** 4242",
583
+ "nameTruncated": false, "timeMs": -0.538, "attributes": { "entries": [
584
+ { "key": "effect.fiberId", "keyTruncated": false, "value": 1,
585
+ "valueTruncated": false }, ... ], "omittedKeys": 0 } } ] } } }
586
+
587
+ RESULT FIELDS
588
+ attributes entries in the span's key order, at most 32; omittedKeys counts the
589
+ rest. key is cut to 100 characters (keyTruncated), so two long keys
590
+ can print alike and still be different entries. value is the JSON
591
+ value, or, when its JSON encoding exceeds 500 characters, the first
592
+ 500 characters of that encoding as a string (valueTruncated true).
593
+ stack Failure stack, at most 4000 characters (stackTruncated), or null.
594
+ parent { kind: "none" } for a root; { kind: "local", spanId, retained } for a
595
+ local parent (retained false: the parent is not in the data);
596
+ { kind: "external", spanId, traceId } for a remote parent.
597
+ ancestry Up to 32 nearest ancestors as { spanId, name, nameTruncated, status,
598
+ startMs, durationMs }, root-most first, ending at the direct parent;
599
+ truncated true when more exist above.
600
+ children total and the first --children child SPAN ITEMs by startMs.
601
+ events total and the first --events events by time:
602
+ { name, nameTruncated, timeMs, attributes }. Effect logs emitted inside
603
+ the span are also recorded as its events (as above); use \`logs --span\`
604
+ for their level and message.
605
+ processMemory Process-wide memory samples within the span's interval (open: up to
606
+ observedUntilMs): { samples, firstSampleMs, lastSampleMs,
607
+ firstHeapUsedBytes, lastHeapUsedBytes, maxHeapUsedBytes }, or null
608
+ when no sample falls in range. Includes all concurrent work in the
609
+ process; not what this span itself used or kept.
610
+ ${spanItem}
611
+ ${context}
612
+ ${timing}
613
+ ${outputRules('span')}
614
+ ${exitTable(spanErrors)}
615
+ `)), Command.withExamples([
616
+ {
617
+ command: 'effect-inspect span --session failing-run-001 --span SPAN_ID --json',
618
+ description: 'SPAN_ID is a spanId copied from summary or spans output',
619
+ },
620
+ {
621
+ command: 'effect-inspect span --file failing-run-001.eitrace --span SPAN_ID --children 100 --json',
622
+ description: 'Offline, listing up to 100 children',
623
+ },
624
+ ]));
625
+ const logsCommand = Command.make('logs', {
626
+ session,
627
+ file,
628
+ url,
629
+ timeoutMs,
630
+ span: Flag.String('span').pipe(Flag.optional, Flag.withDescription('Only logs emitted inside this exact spanId (with --scope). Without it: all logs.')),
631
+ scope: Flag.Literals('scope', ['subtree', 'span']).pipe(Flag.optional, Flag.withDescription('Requires --span. subtree (default): the span and all its descendants; span: that span only.')),
632
+ minLevel: Flag.Literals('min-level', Query.logLevels).pipe(Flag.optional, Flag.withDescription('Only this level and more severe (default: all levels).')),
633
+ ...window('log time within the window'),
634
+ ...page(Query.limits.logs.max, Query.limits.logs.default, 'Logs'),
635
+ json,
636
+ }, (flags) => Effect.flatMap(answer('logs', flags, given({
637
+ spanId: flags.span,
638
+ scope: flags.scope,
639
+ minLevel: flags.minLevel,
640
+ fromMs: flags.fromMs,
641
+ toMs: flags.toMs,
642
+ limit: flags.limit,
643
+ offset: flags.offset,
644
+ })), (response) => emit(response, flags.json))).pipe(Command.withShortDescription('Step 4: logs of one run, by span, level or time window'), Command.withDescription(text(`
645
+ A page of one session's logs in time order. Correlate them with a span using --span
646
+ (logs emitted while that span, or with the default --scope subtree any descendant, was
647
+ the current span), filter by --min-level, or take the logs around a failure with
648
+ --from-ms/--to-ms set from the span's startMs/endMs. A failed span often has no logs of
649
+ its own: try its parent (parentSpanId, or an ancestry item) with --span, or a window.
650
+ ${sourceRules}
651
+
652
+ FILTERS (all combine with AND)
653
+ --span ID Exact spanId; SpanNotFound (exit 4) if it is not retained.
654
+ --scope subtree (default with --span) | span. Without --span it is an
655
+ InvalidRequest, never silently ignored.
656
+ --min-level Trace | Debug | Info | Warn | Error | Fatal; that level and above.
657
+ --from-ms/--to-ms Log timeMs within the window, bounds inclusive. The response's
658
+ "window" field is { "fromMs", "toMs", "match": "within",
659
+ "inclusive": true }, or null. Negative values: --from-ms=-5.
660
+ ${pagingRules(Query.limits.logs.max, Query.limits.logs.default, 'timeMs ascending; equal times keep arrival order')}
661
+
662
+ RESULT (abbreviated)
663
+ { "ok": true, "apiVersion": 1, "op": "logs",
664
+ "query": { "op": "logs", "sessionId": "failing-run-001",
665
+ "spanId": "0a40c31fbf88b7db", "scope": "subtree", "limit": 50, "offset": 0 },
666
+ "source": { ... }, "time": { ... }, "termination": { ... },
667
+ "completeness": { ... }, "conflict": { ... },
668
+ "window": null,
669
+ "result": { "total": 1, "offset": 0, "limit": 50, "nextOffset": null, "items": [
670
+ { "timeMs": -0.506, "level": "Info", "message": "charging card **** 4242",
671
+ "messageTruncated": false, "spanId": "0a40c31fbf88b7db",
672
+ "spanName": "charge.card", "spanNameTruncated": false, "fiberId": 1,
673
+ "annotations": { "entries": [], "omittedKeys": 0 } } ] } }
674
+
675
+ RESULT FIELDS
676
+ level Trace | Debug | Info | Warn | Error | Fatal.
677
+ message At most 2000 characters (messageTruncated); non-string messages are
678
+ JSON-encoded.
679
+ spanId The span current when the log was emitted, or null (outside any span).
680
+ spanName That span's name cut to 200 characters, or null when not retained.
681
+ fiberId Emitting fiber number, or null.
682
+ annotations Same bounded entries shape as span attributes (32 keys, 100-character
683
+ keys, 500-character values, *Truncated flags, omittedKeys).
684
+ A Warn log carrying the annotation effect_inspect.dropped means the program dropped
685
+ telemetry; it is counted in completeness.clientDroppedMessages.
686
+ ${context}
687
+ ${outputRules('logs')}
688
+ ${exitTable(spanErrors)}
689
+ `)), Command.withExamples([
690
+ {
691
+ command: 'effect-inspect logs --session failing-run-001 --span SPAN_ID --json',
692
+ description: 'Logs inside a span and its descendants (SPAN_ID copied from spans output)',
693
+ },
694
+ {
695
+ command: 'effect-inspect logs --session failing-run-001 --min-level Warn --json',
696
+ description: 'Every warning or worse in the run',
697
+ },
698
+ {
699
+ command: 'effect-inspect logs --file failing-run-001.eitrace --from-ms=0 --to-ms 100 --json',
700
+ description: 'Offline: logs from the first 100 ms',
701
+ },
702
+ ]));
703
+ const exportCommand = Command.make('export', {
704
+ session: Flag.String('session').pipe(Flag.optional, Flag.withDescription('Required. Exact ID of the live session to save.')),
705
+ out: Flag.String('out').pipe(Flag.optional, Flag.withDescription('Required. Path of the .eitrace file to create.')),
706
+ force: Flag.Boolean('force').pipe(Flag.withDefault(false), Flag.withDescription('Replace --out if it already exists (default: refuse).')),
707
+ url,
708
+ timeoutMs,
709
+ json,
710
+ }, (flags) => Effect.flatMap(Effect.gen(function* () {
711
+ if (Option.isNone(flags.session) || Option.isNone(flags.out)) {
712
+ return invalid('export', 'export needs --session ID (the live session to save) and --out PATH (the file to create).', 'Example: effect-inspect export --session my-run-001 --out my-run-001.eitrace');
713
+ }
714
+ const sessionId = flags.session.value;
715
+ const out = flags.out.value;
716
+ const target = yield* liveTarget('export', flags);
717
+ const exported = yield* Client.exportTrace(target, sessionId).pipe(Effect.provide(FetchHttpClient.layer));
718
+ if (!exported.ok)
719
+ return exported;
720
+ const fs = yield* FileSystem;
721
+ yield* fs
722
+ .writeFileString(out, exported.text, { flag: flags.force ? 'w' : 'wx' })
723
+ .pipe(Effect.mapError((error) => Query.failure('export', 'OutputError', `The trace could not be written: ${error.message}`, flags.force
724
+ ? 'Check that the directory exists and is writable.'
725
+ : 'Choose a new --out path, or pass --force to replace the existing file; check that the directory exists and is writable.', { file: out })));
726
+ return {
727
+ ok: true,
728
+ apiVersion: Query.apiVersion,
729
+ op: 'export',
730
+ query: { op: 'export', sessionId, out, force: flags.force },
731
+ result: { sessionId, file: out, bytes: new TextEncoder().encode(exported.text).length },
732
+ };
733
+ }).pipe(Effect.catch((failure) => Effect.succeed(failure))), (response) => emit(response, flags.json))).pipe(Command.withShortDescription('Step 5: save one live session to a .eitrace for offline queries'), Command.withDescription(text(`
734
+ Download one live session from the collector and write it to a new .eitrace file:
735
+ the frozen snapshot's protocol messages plus a header with the session's clock and the
736
+ collector's loss counters. Every query command then answers from the file with
737
+ --file PATH exactly as it answered live at that moment (only source.kind, source.file
738
+ and source.snapshotAtEpochMillis differ), with no collector running. The web UI opens
739
+ the same files. The collector keeps traces in memory only; export what you need
740
+ before it stops.
741
+
742
+ SOURCE AND FILE
743
+ --session ID Required, exact live ID (never the newest session). Export is live
744
+ only; to copy a saved trace, copy the file.
745
+ --out PATH Required. Created with exclusive create: an existing file is never
746
+ overwritten unless --force. Relative paths resolve against the
747
+ current directory. Nothing is written when the download fails.
748
+ Collector address: --url, else http://localhost:$EFFECT_INSPECT_PORT, else
749
+ http://localhost:${defaultPort}. --timeout-ms (default ${Client.defaultTimeoutMs}) bounds the whole download;
750
+ raise it for very large sessions.
751
+
752
+ SIZE AND COMPLETENESS
753
+ The file is a lossless artifact, not a query answer: it is NOT held to the 1 MiB
754
+ JSON limit and can be as large as the retained session (the collector keeps up to
755
+ EFFECT_INSPECT_CAPACITY messages per session, default 200000). An active session is
756
+ saved as of the download; later telemetry is not in the file. A session with an ID
757
+ conflict still exports, so the evidence is kept, but queries on the file then fail
758
+ with SessionConflict, as live ones do. Run \`summary --file PATH\` to read the
759
+ file's completeness.
760
+
761
+ RESULT
762
+ { "ok": true, "apiVersion": 1, "op": "export",
763
+ "query": { "op": "export", "sessionId": "failing-run-001",
764
+ "out": "failing-run-001.eitrace", "force": false },
765
+ "result": { "sessionId": "failing-run-001", "file": "failing-run-001.eitrace",
766
+ "bytes": 5321 } }
767
+ bytes UTF-8 size of the file written. The trace itself never goes to stdout.
768
+ ${outputRules('export', false)}
769
+ ${exitTable(['InvalidRequest', 'SessionNotFound', 'ResponseTooLarge', 'CollectorUnavailable', 'CollectorError', 'OutputError'])}
770
+ (ResponseTooLarge here only guards an oversized error reply.)
771
+ `)), Command.withExamples([
772
+ {
773
+ command: 'effect-inspect export --session failing-run-001 --out failing-run-001.eitrace --json',
774
+ description: 'Save the run, then query it offline',
775
+ },
776
+ { command: 'effect-inspect summary --file failing-run-001.eitrace --json' },
777
+ ]));
778
+ /** The query subcommands, in investigation order. */
779
+ export const queryCommands = [
780
+ summaryCommand,
781
+ spansCommand,
782
+ spanCommand,
783
+ logsCommand,
784
+ exportCommand,
785
+ sessionsCommand,
786
+ ];
787
+ const queryNames = new Set(queryCommands.map((command) => command.name));
788
+ // ---------------------------------------------------------------------------
789
+ // Running
790
+ // ---------------------------------------------------------------------------
791
+ /**
792
+ * Runs `cli` on `args` and resolves to the process exit code.
793
+ *
794
+ * For a query command (without `--help`/`--version`) the parser's own help dump
795
+ * on a usage error is suppressed: its errors become one `InvalidRequest` JSON
796
+ * on stdout, a stderr diagnostic and exit 2, so stdout stays machine-readable.
797
+ * Other invocations (`start`, the root, help) render exactly as the framework
798
+ * does.
799
+ */
800
+ export const runCli = (cli, version, args) => Effect.gen(function* () {
801
+ const help = args.some((arg) => arg === '--help' ||
802
+ arg === '-h' ||
803
+ arg === '--version' ||
804
+ arg === '-v' ||
805
+ arg.startsWith('--completions'));
806
+ const op = args.find((arg) => queryNames.has(arg));
807
+ const quiet = !help && op !== undefined;
808
+ const console = yield* Console.Console;
809
+ return yield* Command.runWith(cli, { version, renderErrors: !quiet })(args).pipe(Effect.as(0), Effect.catch((error) => {
810
+ if (error instanceof CliExit)
811
+ return Effect.succeed(error.code);
812
+ if (!CliError.isCliError(error))
813
+ return Effect.fail(error);
814
+ const errors = error._tag === 'ShowHelp' ? error.errors : [error];
815
+ if (errors.length === 0)
816
+ return Effect.succeed(0);
817
+ if (!quiet || op === undefined)
818
+ return Effect.succeed(exitCodes.InvalidRequest);
819
+ return emit(invalid(op, errors.map((each) => each.message).join(' '), `Run \`effect-inspect ${op} --help\` for the flags, their types and allowed values.`), args.includes('--json')).pipe(Effect.as(exitCodes.InvalidRequest), Effect.catch((exit) => Effect.succeed(exit.code)));
820
+ }), quiet
821
+ ? Effect.provideService(Console.Console, Object.assign(Object.create(console), { log: () => { } }))
822
+ : (self) => self);
823
+ });