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.
- 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 +823 -0
- package/dist/cli.d.ts +1 -2
- package/dist/cli.js +165 -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 +689 -0
- package/dist/query/Query.js +1017 -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,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
|
+
});
|