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
package/dist/cli.d.ts CHANGED
@@ -1,3 +1,2 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from 'effect/unstable/cli';
3
- export declare const cli: Command.Command<"effect-inspect", {}, {}, import("effect/Config").ConfigError | import("effect/unstable/http/HttpServerError").ServeError, import("effect/Scope").Scope>;
2
+ export {};
package/dist/cli.js CHANGED
@@ -6,24 +6,181 @@ import * as NodeServices from '@effect/platform-node/NodeServices';
6
6
  // oxlint-disable-next-line effecttsgo/node-builtin-import
7
7
  import { createServer } from 'node:http';
8
8
  import { fileURLToPath } from 'node:url';
9
- import { Effect, Layer, Schema } from 'effect';
9
+ import { Effect, Exit, Layer, Runtime, Schema } from 'effect';
10
10
  import { FileSystem } from 'effect/FileSystem';
11
- import { Command } from 'effect/unstable/cli';
12
- import { collectorConfig } from './collector/Config.js';
11
+ import * as Stdio from 'effect/Stdio';
12
+ import { CliConfig, Command, GlobalFlag } from 'effect/unstable/cli';
13
+ import { queryCommands, runCli } from './cli/QueryCommands.js';
14
+ import { collectorConfig, defaultPort } from './collector/Config.js';
13
15
  import { run } from './collector/Server.js';
14
- import { layer as storeLayer } from './collector/Store.js';
16
+ import { defaultCapacity, layer as storeLayer } from './collector/Store.js';
15
17
  import { loadWebApp } from './collector/WebApp.js';
18
+ /** Indents continuation lines under the help formatter's DESCRIPTION heading. */
19
+ const text = (body) => body.trim().split('\n').join('\n ');
16
20
  const start = Command.make('start', {}, () => Effect.gen(function* () {
17
21
  const { capacity, port } = yield* collectorConfig;
18
22
  const fetch = yield* loadWebApp;
19
23
  yield* Effect.logInfo(`effect-inspect listening at http://localhost:${port}`);
20
24
  return yield* Effect.provide(run(fetch), Layer.mergeAll(storeLayer({ capacity }), NodeHttpServer.layer(createServer, { port })));
21
- })).pipe(Command.withDescription('Start the collector and web UI (configured by EFFECT_INSPECT_PORT and EFFECT_INSPECT_CAPACITY)'));
22
- export const cli = Command.make('effect-inspect').pipe(Command.withDescription('Inspect Effect programs'), Command.withSubcommands([start]));
25
+ })).pipe(Command.withShortDescription('Start the collector and web UI (configured by EFFECT_INSPECT_PORT and EFFECT_INSPECT_CAPACITY)'), Command.withDescription(text(`
26
+ Start the collector and web UI (configured by EFFECT_INSPECT_PORT and EFFECT_INSPECT_CAPACITY).
27
+
28
+ The collector receives telemetry from programs instrumented with Inspect.layer(),
29
+ keeps it in memory per session, serves the web UI at http://localhost:PORT/ and
30
+ answers the query commands (summary, spans, span, logs, export, sessions) on the same
31
+ port. It runs in the foreground until stopped (Ctrl-C or SIGTERM); stopping it drops
32
+ every trace it held, so \`export\` the sessions you want to keep first. Leave it
33
+ running in its own terminal or as a background process while you query.
34
+
35
+ ENVIRONMENT
36
+ EFFECT_INSPECT_PORT Port to listen on (default ${defaultPort}). Query commands read
37
+ the same variable to find the collector unless --url is given.
38
+ EFFECT_INSPECT_CAPACITY Messages retained per session (default ${defaultCapacity}); older ones
39
+ are evicted and reported as completeness.collectorDroppedMessages.
40
+
41
+ CONNECTING PROGRAMS
42
+ Programs connect to ws://localhost:${defaultPort} by default. Inspect.layer() does not read
43
+ EFFECT_INSPECT_PORT: on another port pass Inspect.layer({ url: "ws://localhost:PORT" }).
44
+ (The examples in the effect-inspect repository do follow EFFECT_INSPECT_PORT.)
45
+ If no collector is listening, instrumented programs run normally but record nothing.
46
+
47
+ OUTPUT AND EXIT
48
+ Logs "effect-inspect listening at http://localhost:PORT" on stdout once ready.
49
+ Exits 1 if the port is taken or the configuration is invalid; 130 on Ctrl-C.
50
+ `)), Command.withExamples([
51
+ { command: 'effect-inspect start', description: 'Collector and UI on the default port' },
52
+ {
53
+ command: 'EFFECT_INSPECT_PORT=34500 effect-inspect start',
54
+ description: 'On another port; query it with EFFECT_INSPECT_PORT=34500 or --url',
55
+ },
56
+ ]));
57
+ const rootHelp = text(`
58
+ Inspect Effect programs: record their spans, span events, logs and memory samples,
59
+ and query one exact run as JSON, live from a collector or offline from a saved
60
+ .eitrace file. Written for coding agents: every query command prints one JSON
61
+ document on stdout, diagnostics on stderr, and a distinct exit code per outcome.
62
+ In this help, effect-inspect means however you run this CLI (npx effect-inspect,
63
+ or node dist/cli.js in a built checkout of the repository).
64
+
65
+ WHAT MUST BE TRUE
66
+ 1. The program is instrumented: it provides Inspect.layer() from the effect-inspect
67
+ package (program.pipe(Effect.provide(Inspect.layer()))). Only Effect spans
68
+ (Effect.withSpan), span annotations/events, Effect logs and memory samples are
69
+ recorded. Nothing here instruments code for you, and no environment variable does.
70
+ 2. For live queries a collector is running (\`effect-inspect start\`, leave it running)
71
+ and the program reaches it (ws://localhost:${defaultPort} by default). The collector
72
+ keeps sessions in memory until it stops, so a finished run can still be queried.
73
+ 3. For offline queries you only need a .eitrace file (\`export\`); no collector.
74
+
75
+ CHOOSE THE SESSION ID BEFORE LAUNCH
76
+ EFFECT_INSPECT_SESSION_ID=my-run-001 <command that runs the instrumented program>
77
+ then query exactly that run with --session my-run-001; no need to list sessions.
78
+ IDs: 1-128 ASCII letters, digits, ".", "_" or "-", starting with a letter or digit.
79
+ Use a new ID for every run, retry and instrumented child process. The program's
80
+ Inspect.layer({ sessionId }) option overrides the variable; with neither, the run
81
+ gets a random UUID (find it with \`sessions\`). An invalid or set-but-empty value
82
+ disables recording with a warning in the program's own logs; the program still
83
+ runs. A second run announcing an ID the collector holds is refused, the first run's
84
+ data is kept, and queries for that ID fail with SessionConflict instead of mixing.
85
+
86
+ INVESTIGATION STEPS
87
+ 1. summary --session ID counts, failures, longest spans, completeness
88
+ 2. spans --session ID --status failed find spans; --sort duration|outsideChildren
89
+ 3. span --session ID --span SPAN_ID error, stack, attributes, ancestry, children
90
+ 4. logs --session ID --span SPAN_ID logs in a span's subtree, or --from-ms/--to-ms
91
+ 5. export --session ID --out FILE save the run; repeat 1-4 with --file FILE
92
+ sessions lists sessions, only when you do not know the ID.
93
+
94
+ SOURCES (every per-session query needs exactly one)
95
+ --session ID live: the collector at --url, else http://localhost:$EFFECT_INSPECT_PORT,
96
+ else http://localhost:${defaultPort}. Exact match; the newest session is never
97
+ assumed and an unknown ID is SessionNotFound, never another run.
98
+ --file PATH offline: a saved .eitrace; adding --session asserts the file's ID.
99
+
100
+ OUTPUT AND EXIT CODES (all query commands)
101
+ stdout: one JSON document, pretty-printed; --json prints it compact on one line.
102
+ Success {"ok":true,"apiVersion":1,"op",...,"result"}; failure {"ok":false,
103
+ "apiVersion":1,"op","error":{"_tag","message","hint",...}}. The whole stdout,
104
+ newline included, is at most 1048576 bytes in either mode; pretty output is
105
+ larger, so a page that only fits compact gives ResponseTooLarge: add --json.
106
+ stderr: empty on success; a one-line diagnostic and a hint on failure.
107
+ 0 ok (empty results too: result.total 0) 1 internal error
108
+ 2 InvalidRequest 3 SessionNotFound 4 SpanNotFound
109
+ 5 SessionConflict 6 ResponseTooLarge 7 TraceFileError
110
+ 8 CollectorUnavailable 9 CollectorError 10 OutputError (export)
111
+ Each command's --help explains its errors and what to do next.
112
+
113
+ JSON SHAPE (success, per-session queries)
114
+ Top-level keys: ok, apiVersion, op, query, notices (summary only), source, time,
115
+ termination, completeness, conflict, result (spans and logs add window).
116
+ Context keys are siblings of result, never inside it: .completeness, not
117
+ .result.completeness. Most-used paths:
118
+ .notices[] summary: read first; { code, message } facts easy to miss
119
+ .result.spans.open summary: span counts by status (also total, ok, error, ...)
120
+ .result.unfinished summary: innermost open spans and their open ancestors
121
+ .result.longest[].durationMs summary: completed spans only, longest first
122
+ .result.memory summary: heap peak, sampling gaps; null without samples
123
+ .result.items[] spans and logs: the page (with total, nextOffset)
124
+ .completeness.status noLossRecorded | lossRecorded | unknown
125
+ .termination.state active | ended | unknown
126
+ Full field lists: \`<command> --help\` (RESULT FIELDS or SPAN ITEM FIELDS, CONTEXT FIELDS).
127
+
128
+ EVIDENCE AND TIMING
129
+ Times are milliseconds since the session's clock origin (when its inspect client
130
+ started) and can be negative. durationMs is elapsed wall time; outsideChildrenMs is
131
+ elapsed time not covered by recorded child spans. Neither is CPU time or a verdict
132
+ that something is slow, and an open span (no end recorded) is not a deadlock.
133
+ Before concluding that something did not happen, read "completeness": lossRecorded
134
+ means evidence is missing, and noLossRecorded is not proof of completeness.
135
+
136
+ Run \`effect-inspect <command> --help\` for the full flags, defaults, JSON fields and
137
+ errors of each command.
138
+ `);
139
+ const cli = Command.make('effect-inspect').pipe(Command.withDescription(rootHelp), Command.withSubcommands([start, ...queryCommands]), Command.withExamples([
140
+ {
141
+ command: 'effect-inspect start',
142
+ description: 'Terminal 1: start the collector and leave it running',
143
+ },
144
+ {
145
+ command: 'EFFECT_INSPECT_SESSION_ID=failing-run-001 bun examples/failing.ts',
146
+ description: 'Terminal 2: run an instrumented program under a chosen ID (a repository example)',
147
+ },
148
+ {
149
+ command: 'effect-inspect summary --session failing-run-001 --json',
150
+ description: 'Overview of exactly that run',
151
+ },
152
+ {
153
+ command: 'effect-inspect spans --session failing-run-001 --status failed --json',
154
+ description: 'Its failed spans; copy a spanId and its parentSpanId',
155
+ },
156
+ { command: 'effect-inspect span --session failing-run-001 --span SPAN_ID --json' },
157
+ { command: 'effect-inspect logs --session failing-run-001 --span PARENT_SPAN_ID --json' },
158
+ {
159
+ command: 'effect-inspect export --session failing-run-001 --out failing-run-001.eitrace --json',
160
+ description: 'Save it, then query the file with no collector',
161
+ },
162
+ { command: 'effect-inspect summary --file failing-run-001.eitrace --json' },
163
+ { command: 'effect-inspect spans --file failing-run-001.eitrace --status failed --json' },
164
+ ]));
23
165
  const main = Effect.gen(function* () {
24
166
  const fs = yield* FileSystem;
25
167
  const packageJson = yield* fs.readFileString(fileURLToPath(new URL('../package.json', import.meta.url)));
26
168
  const { version } = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })))(packageJson);
27
- return yield* Command.run(cli, { version });
169
+ const args = yield* (yield* Stdio.Stdio).args;
170
+ return yield* runCli(cli, version, args);
171
+ });
172
+ NodeRuntime.runMain(main.pipe(Effect.scoped,
173
+ // No interactive wizard: agents drive this CLI without a terminal.
174
+ Effect.provide(Layer.mergeAll(NodeServices.layer, CliConfig.layer({
175
+ builtIns: [
176
+ GlobalFlag.Help,
177
+ GlobalFlag.Version,
178
+ GlobalFlag.Completions,
179
+ GlobalFlag.LogLevel,
180
+ ],
181
+ })))), {
182
+ // `main` succeeds with the exit code; failures keep the default mapping.
183
+ teardown: (exit, onExit) => Exit.isSuccess(exit) && typeof exit.value === 'number'
184
+ ? onExit(exit.value)
185
+ : Runtime.defaultTeardown(exit, onExit),
28
186
  });
29
- NodeRuntime.runMain(main.pipe(Effect.scoped, Effect.provide(NodeServices.layer)));
@@ -24,7 +24,7 @@
24
24
  * socket slow enough for a realistic burst to overrun the queue in the first
25
25
  * place.
26
26
  */
27
- import { Context, Effect, type Scope } from 'effect';
27
+ import { Context, Effect, Result, type Scope } from 'effect';
28
28
  import type { Socket } from 'effect/unstable/socket';
29
29
  import * as Protocol from '../protocol/Schema.ts';
30
30
  declare const InspectClient_base: Context.ServiceClass<InspectClient, "effect-inspect/client/InspectClient", {
@@ -39,8 +39,21 @@ declare const InspectClient_base: Context.ServiceClass<InspectClient, "effect-in
39
39
  */
40
40
  export declare class InspectClient extends InspectClient_base {
41
41
  }
42
+ /** Environment variable a launcher sets to choose the session ID. */
43
+ export declare const sessionIdEnv = "EFFECT_INSPECT_SESSION_ID";
42
44
  /** Options accepted by the inspect layers. */
43
45
  export interface Options {
46
+ /**
47
+ * The session ID this run reports under, e.g. `checkout-before-1`, so it can
48
+ * be queried by that exact name. Takes precedence over the
49
+ * `EFFECT_INSPECT_SESSION_ID` environment variable; a random UUID is used
50
+ * when neither is set. Must satisfy {@link Protocol.isValidSessionId}.
51
+ *
52
+ * Use one ID per run: a second client instance announcing an ID the
53
+ * collector already holds is refused as a collision, and its telemetry is
54
+ * discarded — by a collector from this release on; older ones merge them.
55
+ */
56
+ readonly sessionId?: string | undefined;
44
57
  /** Name shown for this program in the webapp. Defaults to the entry script's file name. */
45
58
  readonly programName?: string | undefined;
46
59
  /** Outbound queue capacity, in messages. Defaults to 131072 (~34 MB). */
@@ -54,12 +67,40 @@ export interface Options {
54
67
  */
55
68
  readonly memoryIntervalMillis?: number | undefined;
56
69
  }
70
+ /**
71
+ * Picks this client's session ID: the `sessionId` option, else the exact
72
+ * `EFFECT_INSPECT_SESSION_ID` key of the environment, else a random UUID.
73
+ *
74
+ * | Input | Result |
75
+ * | --------------------------------------- | ------------------------ |
76
+ * | option given (env never touched) | the option, if valid |
77
+ * | no environment, or exact key absent | random UUID |
78
+ * | exact key set, valid | that value |
79
+ * | exact key set but empty, or invalid | failure (diagnostic) |
80
+ * | environment exists but cannot be read | failure (diagnostic) |
81
+ *
82
+ * `env` yields the raw record, read directly rather than through Effect's
83
+ * `ConfigProvider`: the env provider reports an empty value as missing, and
84
+ * this setting has to tell a set-but-empty variable (`ID=$UNSET_VAR`, a
85
+ * launcher mistake) from an absent one. So only the exact key counts —
86
+ * similarly prefixed variables are irrelevant. An unreadable environment is
87
+ * not treated as absence: the launcher may well have chosen an ID there.
88
+ *
89
+ * Never throws: a failure is a diagnostic, and the caller records nothing
90
+ * rather than report under a substituted ID nobody can find. Exported for
91
+ * tests only; not part of the public API.
92
+ */
93
+ export declare const resolveSessionId: (options: Options | undefined, env?: () => Readonly<Record<string, string | undefined>> | undefined) => Result.Result<Protocol.SessionId, string>;
57
94
  /**
58
95
  * Builds the client service and forks the fiber that owns the connection.
59
96
  *
60
97
  * The returned effect never fails and never waits for the collector: if it is
61
98
  * unreachable the fiber retries in the background while `sendUnsafe` keeps
62
99
  * accepting (and dropping) messages, so the host program is unaffected.
100
+ *
101
+ * The session ID is resolved once, here, and re-announced unchanged on every
102
+ * reconnect. If the chosen ID is invalid the client logs a warning and records
103
+ * nothing, rather than reporting under an ID nobody asked for.
63
104
  */
64
105
  export declare const make: (options?: Options) => Effect.Effect<InspectClient['Service'], never, Scope.Scope | Socket.Socket>;
65
106
  export {};
@@ -92,6 +92,8 @@ const reconnectSchedule = Schedule.exponential(Duration.millis(250)).pipe(Schedu
92
92
  */
93
93
  export class InspectClient extends Context.Service()('effect-inspect/client/InspectClient') {
94
94
  }
95
+ /** Environment variable a launcher sets to choose the session ID. */
96
+ export const sessionIdEnv = 'EFFECT_INSPECT_SESSION_ID';
95
97
  /**
96
98
  * The name this program lists as.
97
99
  *
@@ -128,6 +130,77 @@ const memoryUsage = () => {
128
130
  const usage = globalThis.process?.memoryUsage;
129
131
  return typeof usage === 'function' ? usage.bind(globalThis.process) : undefined;
130
132
  };
133
+ /**
134
+ * The raw process environment, or `undefined` when the runtime has none.
135
+ *
136
+ * Throws when an environment exists but may not be read. Deno is asked first,
137
+ * with `permissions.querySync`, which reports `granted` / `prompt` / `denied`
138
+ * without ever prompting: reading `process.env` there without permission
139
+ * either throws `NotCapable` or, in a terminal, stops the host program at an
140
+ * interactive prompt — and inspection must never block the program it
141
+ * watches.
142
+ */
143
+ const processEnv = () => {
144
+ const process = globalThis.process;
145
+ if (process === undefined)
146
+ return undefined;
147
+ const deno = globalThis.Deno;
148
+ if (deno !== undefined) {
149
+ const state = deno.permissions?.querySync?.({ name: 'env', variable: sessionIdEnv }).state;
150
+ if (state !== 'granted') {
151
+ throw new Error(`env access is ${state ?? 'unknown'} (Deno: --allow-env=${sessionIdEnv})`);
152
+ }
153
+ }
154
+ return process.env;
155
+ };
156
+ /**
157
+ * Picks this client's session ID: the `sessionId` option, else the exact
158
+ * `EFFECT_INSPECT_SESSION_ID` key of the environment, else a random UUID.
159
+ *
160
+ * | Input | Result |
161
+ * | --------------------------------------- | ------------------------ |
162
+ * | option given (env never touched) | the option, if valid |
163
+ * | no environment, or exact key absent | random UUID |
164
+ * | exact key set, valid | that value |
165
+ * | exact key set but empty, or invalid | failure (diagnostic) |
166
+ * | environment exists but cannot be read | failure (diagnostic) |
167
+ *
168
+ * `env` yields the raw record, read directly rather than through Effect's
169
+ * `ConfigProvider`: the env provider reports an empty value as missing, and
170
+ * this setting has to tell a set-but-empty variable (`ID=$UNSET_VAR`, a
171
+ * launcher mistake) from an absent one. So only the exact key counts —
172
+ * similarly prefixed variables are irrelevant. An unreadable environment is
173
+ * not treated as absence: the launcher may well have chosen an ID there.
174
+ *
175
+ * Never throws: a failure is a diagnostic, and the caller records nothing
176
+ * rather than report under a substituted ID nobody can find. Exported for
177
+ * tests only; not part of the public API.
178
+ */
179
+ export const resolveSessionId = (options, env = processEnv) => {
180
+ const fromOption = options?.sessionId;
181
+ const source = fromOption === undefined ? sessionIdEnv : 'the sessionId option';
182
+ let chosen = fromOption;
183
+ if (chosen === undefined) {
184
+ try {
185
+ chosen = env()?.[sessionIdEnv];
186
+ }
187
+ catch (error) {
188
+ return Result.fail(`${sessionIdEnv} could not be read: ${String(error)}`);
189
+ }
190
+ }
191
+ if (chosen === undefined) {
192
+ // Effect's `Crypto` can fail with a PlatformError and nothing on this path
193
+ // is allowed to fail; a session id needs uniqueness, not strength.
194
+ // oxlint-disable-next-line effecttsgo/crypto-random-uuid
195
+ return Result.succeed(crypto.randomUUID());
196
+ }
197
+ if (chosen === '')
198
+ return Result.fail(`${source} is set but empty: unset it or choose an ID`);
199
+ if (!Protocol.isValidSessionId(chosen)) {
200
+ return Result.fail(`${source} is not a valid session ID "${chosen}": use ${Protocol.sessionIdRule}`);
201
+ }
202
+ return Result.succeed(chosen);
203
+ };
131
204
  /**
132
205
  * Forks the fiber that samples process memory into the outbound queue.
133
206
  *
@@ -166,15 +239,25 @@ const forkMemorySampler = (deps) => Effect.suspend(() => {
166
239
  * The returned effect never fails and never waits for the collector: if it is
167
240
  * unreachable the fiber retries in the background while `sendUnsafe` keeps
168
241
  * accepting (and dropping) messages, so the host program is unaffected.
242
+ *
243
+ * The session ID is resolved once, here, and re-announced unchanged on every
244
+ * reconnect. If the chosen ID is invalid the client logs a warning and records
245
+ * nothing, rather than reporting under an ID nobody asked for.
169
246
  */
170
247
  export const make = (options) => Effect.gen(function* () {
248
+ const resolved = resolveSessionId(options);
249
+ if (Result.isFailure(resolved)) {
250
+ yield* Effect.logWarning(`effect-inspect disabled: ${resolved.failure}`);
251
+ return InspectClient.of({ sessionId: '', sendUnsafe: () => { } });
252
+ }
253
+ const sessionId = resolved.success;
254
+ // Distinguishes this client from an independent run that chose the same
255
+ // session ID; fixed for the client's lifetime so a reconnect still matches.
256
+ // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect
257
+ const instanceId = crypto.randomUUID();
171
258
  const socket = yield* SocketService.Socket;
172
259
  const capacity = options?.bufferSize ?? defaultBufferSize;
173
260
  const queue = yield* Queue.dropping(capacity);
174
- // Effect's `Crypto` can fail with a PlatformError and nothing on this path
175
- // is allowed to fail; a session id needs uniqueness, not strength.
176
- // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect
177
- const sessionId = crypto.randomUUID();
178
261
  // Tracked here rather than inside the queue so a drop survives a
179
262
  // reconnect: it is reported on the next `Hello`, which every reconnect
180
263
  // re-sends.
@@ -204,6 +287,7 @@ export const make = (options) => Effect.gen(function* () {
204
287
  pid: globalThis.process?.pid ?? 0,
205
288
  runtime: runtimeName(),
206
289
  protocolVersion: Protocol.protocolVersion,
290
+ instanceId,
207
291
  clock: {
208
292
  startTime: clock.currentTimeNanosUnsafe(),
209
293
  // Deliberately the wall clock: this is the anchor that maps the
@@ -0,0 +1,29 @@
1
+ /**
2
+ * The collector's read-only query API over HTTP, on the collector's port.
3
+ *
4
+ * - `POST /api/v1/query` — body: a `Query.QueryRequest` JSON object. Reply:
5
+ * a `Query.QueryResponse` JSON object. The body is authoritative; the
6
+ * status mirrors it (200 ok, 400 InvalidRequest, 404 SessionNotFound or
7
+ * SpanNotFound, 409 SessionConflict, 413 ResponseTooLarge). Every JSON
8
+ * reply, errors included, is held to `Query.limits.responseBytes`.
9
+ * - `GET /api/v1/export?sessionId=ID` — the session's frozen snapshot as
10
+ * `.eitrace` text with its loss counters in the header (200), or a
11
+ * `QueryFailure` JSON body (400/404). A conflicted session still exports,
12
+ * so the evidence is kept; queries against the file refuse it the same way.
13
+ * Export is a lossless artifact transfer, so it is not held to the JSON
14
+ * response bound: its size is the retained session (up to capacity).
15
+ *
16
+ * Each request answers from one atomic snapshot of one session. Two requests
17
+ * against an active session see different snapshots; compare
18
+ * `completeness.messagesObserved` to tell whether data changed between pages.
19
+ */
20
+ import { Effect } from 'effect';
21
+ import { HttpServerRequest, HttpServerResponse } from 'effect/unstable/http';
22
+ import * as Query from '../query/Query.ts';
23
+ import { Store } from './Store.ts';
24
+ /** Path prefix of the query API. */
25
+ export declare const apiPath = "/api/v1/";
26
+ /** Answers one decoded request from the store. */
27
+ export declare const answer: (input: unknown) => Effect.Effect<Query.QueryResponse, never, Store>;
28
+ /** Handles a request under {@link apiPath}. */
29
+ export declare const handle: (request: HttpServerRequest.HttpServerRequest, url: URL) => Effect.Effect<HttpServerResponse.HttpServerResponse, never, Store>;
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The collector's read-only query API over HTTP, on the collector's port.
3
+ *
4
+ * - `POST /api/v1/query` — body: a `Query.QueryRequest` JSON object. Reply:
5
+ * a `Query.QueryResponse` JSON object. The body is authoritative; the
6
+ * status mirrors it (200 ok, 400 InvalidRequest, 404 SessionNotFound or
7
+ * SpanNotFound, 409 SessionConflict, 413 ResponseTooLarge). Every JSON
8
+ * reply, errors included, is held to `Query.limits.responseBytes`.
9
+ * - `GET /api/v1/export?sessionId=ID` — the session's frozen snapshot as
10
+ * `.eitrace` text with its loss counters in the header (200), or a
11
+ * `QueryFailure` JSON body (400/404). A conflicted session still exports,
12
+ * so the evidence is kept; queries against the file refuse it the same way.
13
+ * Export is a lossless artifact transfer, so it is not held to the JSON
14
+ * response bound: its size is the retained session (up to capacity).
15
+ *
16
+ * Each request answers from one atomic snapshot of one session. Two requests
17
+ * against an active session see different snapshots; compare
18
+ * `completeness.messagesObserved` to tell whether data changed between pages.
19
+ */
20
+ import { Clock, Effect, Result } from 'effect';
21
+ import { HttpServerRequest, HttpServerResponse } from 'effect/unstable/http';
22
+ import * as Query from '../query/Query.js';
23
+ import { Store } from './Store.js';
24
+ /** Path prefix of the query API. */
25
+ export const apiPath = '/api/v1/';
26
+ const statusOf = (response) => {
27
+ if (response.ok)
28
+ return 200;
29
+ switch (response.error._tag) {
30
+ case 'InvalidRequest':
31
+ return 400;
32
+ case 'SessionNotFound':
33
+ case 'SpanNotFound':
34
+ return 404;
35
+ case 'SessionConflict':
36
+ return 409;
37
+ case 'ResponseTooLarge':
38
+ return 413;
39
+ default:
40
+ return 500;
41
+ }
42
+ };
43
+ /** Every JSON reply goes through the response byte bound, errors included. */
44
+ const reply = (unbounded) => {
45
+ const response = Query.limitResponse(unbounded);
46
+ return HttpServerResponse.jsonUnsafe(response, { status: statusOf(response) });
47
+ };
48
+ /** Answers one decoded request from the store. */
49
+ export const answer = (input) => Effect.gen(function* () {
50
+ const decoded = Query.decodeRequest(input);
51
+ if (Result.isFailure(decoded))
52
+ return decoded.failure;
53
+ const request = decoded.success;
54
+ const store = yield* Store;
55
+ if (request.op === 'sessions') {
56
+ return Query.listSessions({ kind: 'live', file: null }, yield* store.sessions, request);
57
+ }
58
+ if (request.sessionId === undefined)
59
+ return Query.sessionRequired(request.op);
60
+ const snapshot = yield* store.snapshot(request.sessionId);
61
+ if (snapshot === undefined)
62
+ return Query.liveSessionNotFound(request.op, request.sessionId);
63
+ // ponytail: rebuilds the trace model per request (~O(retained messages)),
64
+ // synchronously, which can delay ingest on this event loop meanwhile.
65
+ // Cache by `messagesObserved` if repeated queries on huge sessions show up.
66
+ return Query.run(Query.fromSnapshot(snapshot, yield* Clock.currentTimeMillis), request);
67
+ });
68
+ /** Handles a request under {@link apiPath}. */
69
+ export const handle = (request, url) => Effect.gen(function* () {
70
+ if (url.pathname === `${apiPath}query` && request.method === 'POST') {
71
+ const body = yield* Effect.result(request.json);
72
+ if (Result.isFailure(body)) {
73
+ return reply(Query.failure(null, 'InvalidRequest', 'The request body is not JSON.', 'Send a JSON query object.'));
74
+ }
75
+ return reply(yield* answer(body.success));
76
+ }
77
+ if (url.pathname === `${apiPath}export` && request.method === 'GET') {
78
+ const sessionId = url.searchParams.get('sessionId');
79
+ if (sessionId === null)
80
+ return reply(Query.sessionRequired('export'));
81
+ const store = yield* Store;
82
+ const snapshot = yield* store.snapshot(sessionId);
83
+ if (snapshot === undefined)
84
+ return reply(Query.liveSessionNotFound('export', sessionId));
85
+ const source = Query.fromSnapshot(snapshot, yield* Clock.currentTimeMillis);
86
+ return HttpServerResponse.text(Query.toTraceFile(source), {
87
+ contentType: 'application/x-ndjson',
88
+ });
89
+ }
90
+ return reply(Query.failure(null, 'InvalidRequest', `Unknown endpoint ${request.method} ${url.pathname}.`, `Use POST ${apiPath}query or GET ${apiPath}export?sessionId=ID.`));
91
+ });
@@ -20,5 +20,5 @@ export declare const webappPath = "/webapp";
20
20
  * released when that one connection ends, and nothing outlives it.
21
21
  */
22
22
  export declare const handleConnection: (socket: Socket.Socket, path: string) => Effect.Effect<void, never, Store>;
23
- /** Serves instrumented clients, webapp sockets, and the bundled web UI. */
23
+ /** Serves instrumented clients, webapp sockets, the query API and the bundled web UI. */
24
24
  export declare const run: (fetch?: (request: Request) => Promise<Response>) => Effect.Effect<never, never, HttpServer.HttpServer | import("effect/Scope").Scope | Store>;
@@ -11,6 +11,7 @@ import { Effect, Fiber, PubSub, Result } from 'effect';
11
11
  import { HttpServer, HttpServerRequest, HttpServerResponse } from 'effect/unstable/http';
12
12
  import { Socket } from 'effect/unstable/socket';
13
13
  import { clientCodec, collectorCodec, webappCodec, webappRequestCodec } from '../protocol/Codec.js';
14
+ import * as QueryApi from './QueryApi.js';
14
15
  import { Store } from './Store.js';
15
16
  /** Path a webapp client connects on; anything else is an instrumented program. */
16
17
  export const webappPath = '/webapp';
@@ -71,26 +72,29 @@ const handleClient = Effect.fnUntraced(function* (socket) {
71
72
  const store = yield* Store;
72
73
  const pull = yield* readerFor(socket);
73
74
  const writer = yield* socket.writer;
75
+ /** This connection's identity, so the store can tell it from a collision. */
76
+ const connection = Symbol('connection');
74
77
  /** The session this connection belongs to, learned from its `Hello`. */
75
78
  let sessionId;
76
79
  const onMessage = (message) => Effect.gen(function* () {
77
80
  sessionId = message.sessionId;
78
81
  switch (message._tag) {
79
82
  case 'Hello':
80
- yield* store.hello(message);
83
+ yield* store.hello(message, connection);
81
84
  return;
82
85
  case 'Ping':
83
86
  // A failed write means the client is gone; the read loop notices.
84
87
  yield* Effect.ignore(writer.write(collectorCodec.encode({ _tag: 'Pong', sessionId: message.sessionId })));
85
88
  return;
86
89
  default:
87
- yield* store.append(message);
90
+ yield* store.append(message, connection);
88
91
  }
89
92
  });
90
- yield* readLines(pull, clientCodec.decode, onMessage, () => Effect.suspend(() => store.skipLine(sessionId))).pipe(
93
+ yield* readLines(pull, clientCodec.decode, onMessage, () => Effect.suspend(() => store.skipLine(sessionId, connection))).pipe(
91
94
  // However the connection ends — clean close, crash, kill -9 — the session
92
- // is marked ended. A reconnect with the same id resumes it.
93
- Effect.ensuring(Effect.suspend(() => (sessionId === undefined ? Effect.void : store.end(sessionId)))));
95
+ // is marked ended, if this connection still owns it. A reconnect from the
96
+ // same client instance resumes it.
97
+ Effect.ensuring(Effect.suspend(() => sessionId === undefined ? Effect.void : store.end(sessionId, connection))));
94
98
  });
95
99
  /** Handles one webapp client's connection for its lifetime. */
96
100
  const handleWebapp = Effect.fnUntraced(function* (socket) {
@@ -146,12 +150,15 @@ const handleWebapp = Effect.fnUntraced(function* (socket) {
146
150
  * released when that one connection ends, and nothing outlives it.
147
151
  */
148
152
  export const handleConnection = (socket, path) => Effect.scoped(path === webappPath ? handleWebapp(socket) : handleClient(socket));
149
- /** Serves instrumented clients, webapp sockets, and the bundled web UI. */
153
+ /** Serves instrumented clients, webapp sockets, the query API and the bundled web UI. */
150
154
  export const run = (fetch) => Effect.gen(function* () {
151
155
  const server = yield* HttpServer.HttpServer;
152
156
  yield* server.serve(Effect.gen(function* () {
153
157
  const request = yield* HttpServerRequest.HttpServerRequest;
154
- const path = new URL(request.url, 'http://localhost').pathname;
158
+ const url = new URL(request.url, 'http://localhost');
159
+ const path = url.pathname;
160
+ if (path.startsWith(QueryApi.apiPath))
161
+ return yield* QueryApi.handle(request, url);
155
162
  if (request.headers.upgrade?.toLowerCase() === 'websocket') {
156
163
  const socket = yield* request.upgrade;
157
164
  yield* handleConnection(socket, path);
@@ -162,7 +169,7 @@ export const run = (fetch) => Effect.gen(function* () {
162
169
  status: 426,
163
170
  });
164
171
  }
165
- const response = yield* Effect.promise(() => fetch(new Request(new URL(request.url, 'http://localhost').href, {
172
+ const response = yield* Effect.promise(() => fetch(new Request(url.href, {
166
173
  method: request.method,
167
174
  headers: request.headers,
168
175
  })));