effect-inspect 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +73 -4
  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-B2BMiICr.js +20 -0
  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 +768 -0
  10. package/dist/cli.d.ts +1 -2
  11. package/dist/cli.js +150 -8
  12. package/dist/client/Client.d.ts +59 -5
  13. package/dist/client/Client.js +196 -16
  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 +599 -0
  26. package/dist/query/Query.js +876 -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
  35. package/app/dist/server/assets/_tanstack-start-manifest_v-Co953HeC.js +0 -20
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,166 @@ 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
+ EVIDENCE AND TIMING
114
+ Times are milliseconds since the session's clock origin (when its inspect client
115
+ started) and can be negative. durationMs is elapsed wall time; outsideChildrenMs is
116
+ elapsed time not covered by recorded child spans. Neither is CPU time or a verdict
117
+ that something is slow, and an open span (no end recorded) is not a deadlock.
118
+ Before concluding that something did not happen, read "completeness": lossRecorded
119
+ means evidence is missing, and noLossRecorded is not proof of completeness.
120
+
121
+ Run \`effect-inspect <command> --help\` for the full flags, defaults, JSON fields and
122
+ errors of each command.
123
+ `);
124
+ const cli = Command.make('effect-inspect').pipe(Command.withDescription(rootHelp), Command.withSubcommands([start, ...queryCommands]), Command.withExamples([
125
+ {
126
+ command: 'effect-inspect start',
127
+ description: 'Terminal 1: start the collector and leave it running',
128
+ },
129
+ {
130
+ command: 'EFFECT_INSPECT_SESSION_ID=failing-run-001 bun examples/failing.ts',
131
+ description: 'Terminal 2: run an instrumented program under a chosen ID (a repository example)',
132
+ },
133
+ {
134
+ command: 'effect-inspect summary --session failing-run-001 --json',
135
+ description: 'Overview of exactly that run',
136
+ },
137
+ {
138
+ command: 'effect-inspect spans --session failing-run-001 --status failed --json',
139
+ description: 'Its failed spans; copy a spanId and its parentSpanId',
140
+ },
141
+ { command: 'effect-inspect span --session failing-run-001 --span SPAN_ID --json' },
142
+ { command: 'effect-inspect logs --session failing-run-001 --span PARENT_SPAN_ID --json' },
143
+ {
144
+ command: 'effect-inspect export --session failing-run-001 --out failing-run-001.eitrace --json',
145
+ description: 'Save it, then query the file with no collector',
146
+ },
147
+ { command: 'effect-inspect summary --file failing-run-001.eitrace --json' },
148
+ { command: 'effect-inspect spans --file failing-run-001.eitrace --status failed --json' },
149
+ ]));
23
150
  const main = Effect.gen(function* () {
24
151
  const fs = yield* FileSystem;
25
152
  const packageJson = yield* fs.readFileString(fileURLToPath(new URL('../package.json', import.meta.url)));
26
153
  const { version } = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })))(packageJson);
27
- return yield* Command.run(cli, { version });
154
+ const args = yield* (yield* Stdio.Stdio).args;
155
+ return yield* runCli(cli, version, args);
156
+ });
157
+ NodeRuntime.runMain(main.pipe(Effect.scoped,
158
+ // No interactive wizard: agents drive this CLI without a terminal.
159
+ Effect.provide(Layer.mergeAll(NodeServices.layer, CliConfig.layer({
160
+ builtIns: [
161
+ GlobalFlag.Help,
162
+ GlobalFlag.Version,
163
+ GlobalFlag.Completions,
164
+ GlobalFlag.LogLevel,
165
+ ],
166
+ })))), {
167
+ // `main` succeeds with the exit code; failures keep the default mapping.
168
+ teardown: (exit, onExit) => Exit.isSuccess(exit) && typeof exit.value === 'number'
169
+ ? onExit(exit.value)
170
+ : Runtime.defaultTeardown(exit, onExit),
28
171
  });
29
- NodeRuntime.runMain(main.pipe(Effect.scoped, Effect.provide(NodeServices.layer)));
@@ -4,14 +4,27 @@
4
4
  *
5
5
  * The guarantee that shapes this module is that instrumenting a program must
6
6
  * never break or slow it. So the producer side is a synchronous, non-blocking
7
- * {@link InspectClient} `sendUnsafe` onto a sliding queue, and every transport
7
+ * {@link InspectClient} `sendUnsafe` onto a dropping queue, and every transport
8
8
  * concern — no collector listening, a mid-run disconnect, a consumer slower
9
9
  * than the program — is confined to a background fiber whose failures are
10
- * swallowed and retried. When the program outruns the socket the queue drops
11
- * its oldest messages and reports the count, rather than growing without bound
10
+ * swallowed and retried. When the program outruns the socket the queue refuses
11
+ * the newest messages and reports the count, rather than growing without bound
12
12
  * or pushing backpressure into the fibers being traced.
13
+ *
14
+ * Dropping rather than sliding, because *which* message is lost is the whole
15
+ * problem. A sliding queue evicts its oldest entries, so a burst threw away the
16
+ * `SpanEnd` of a span still running — and the session's `Hello`, the first
17
+ * message there is — while keeping the unrelated messages that caused the
18
+ * overrun. A span whose end was dropped renders as never-ending. Refusing at
19
+ * the tail instead keeps every message already accepted, so loss is a suffix of
20
+ * the burst rather than a hole punched through the session's history.
21
+ *
22
+ * The drain writes each dequeued batch as one newline-delimited frame rather
23
+ * than one frame per message, because a per-message write is what made the
24
+ * socket slow enough for a realistic burst to overrun the queue in the first
25
+ * place.
13
26
  */
14
- import { Context, Effect, type Scope } from 'effect';
27
+ import { Context, Effect, Result, type Scope } from 'effect';
15
28
  import type { Socket } from 'effect/unstable/socket';
16
29
  import * as Protocol from '../protocol/Schema.ts';
17
30
  declare const InspectClient_base: Context.ServiceClass<InspectClient, "effect-inspect/client/InspectClient", {
@@ -26,11 +39,24 @@ declare const InspectClient_base: Context.ServiceClass<InspectClient, "effect-in
26
39
  */
27
40
  export declare class InspectClient extends InspectClient_base {
28
41
  }
42
+ /** Environment variable a launcher sets to choose the session ID. */
43
+ export declare const sessionIdEnv = "EFFECT_INSPECT_SESSION_ID";
29
44
  /** Options accepted by the inspect layers. */
30
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;
31
57
  /** Name shown for this program in the webapp. Defaults to the entry script's file name. */
32
58
  readonly programName?: string | undefined;
33
- /** Outbound queue capacity, in messages. Defaults to 8192. */
59
+ /** Outbound queue capacity, in messages. Defaults to 131072 (~34 MB). */
34
60
  readonly bufferSize?: number | undefined;
35
61
  /**
36
62
  * How often to sample `process.memoryUsage()`, in milliseconds. Defaults to
@@ -41,12 +67,40 @@ export interface Options {
41
67
  */
42
68
  readonly memoryIntervalMillis?: number | undefined;
43
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>;
44
94
  /**
45
95
  * Builds the client service and forks the fiber that owns the connection.
46
96
  *
47
97
  * The returned effect never fails and never waits for the collector: if it is
48
98
  * unreachable the fiber retries in the background while `sendUnsafe` keeps
49
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.
50
104
  */
51
105
  export declare const make: (options?: Options) => Effect.Effect<InspectClient['Service'], never, Scope.Scope | Socket.Socket>;
52
106
  export {};
@@ -4,19 +4,51 @@
4
4
  *
5
5
  * The guarantee that shapes this module is that instrumenting a program must
6
6
  * never break or slow it. So the producer side is a synchronous, non-blocking
7
- * {@link InspectClient} `sendUnsafe` onto a sliding queue, and every transport
7
+ * {@link InspectClient} `sendUnsafe` onto a dropping queue, and every transport
8
8
  * concern — no collector listening, a mid-run disconnect, a consumer slower
9
9
  * than the program — is confined to a background fiber whose failures are
10
- * swallowed and retried. When the program outruns the socket the queue drops
11
- * its oldest messages and reports the count, rather than growing without bound
10
+ * swallowed and retried. When the program outruns the socket the queue refuses
11
+ * the newest messages and reports the count, rather than growing without bound
12
12
  * or pushing backpressure into the fibers being traced.
13
+ *
14
+ * Dropping rather than sliding, because *which* message is lost is the whole
15
+ * problem. A sliding queue evicts its oldest entries, so a burst threw away the
16
+ * `SpanEnd` of a span still running — and the session's `Hello`, the first
17
+ * message there is — while keeping the unrelated messages that caused the
18
+ * overrun. A span whose end was dropped renders as never-ending. Refusing at
19
+ * the tail instead keeps every message already accepted, so loss is a suffix of
20
+ * the burst rather than a hole punched through the session's history.
21
+ *
22
+ * The drain writes each dequeued batch as one newline-delimited frame rather
23
+ * than one frame per message, because a per-message write is what made the
24
+ * socket slow enough for a realistic burst to overrun the queue in the first
25
+ * place.
13
26
  */
14
- import { Context, Duration, Effect, Latch, Queue, Schedule } from 'effect';
27
+ import { Context, Duration, Effect, Latch, Queue, Result, Schedule } from 'effect';
15
28
  import { Socket as SocketService } from 'effect/unstable/socket';
16
29
  import { clientCodec } from '../protocol/Codec.js';
17
30
  import * as Protocol from '../protocol/Schema.js';
18
- /** How many messages may be buffered before the oldest are dropped. */
19
- const defaultBufferSize = 8192;
31
+ /**
32
+ * How many messages may be buffered before new ones are refused.
33
+ *
34
+ * ponytail: the ceiling is counted in messages, not bytes. A message is a plain
35
+ * object held un-encoded, and the honest unit would be its retained size — but
36
+ * the only way to know that in `sendUnsafe` is to encode there, on the hot path
37
+ * of every traced span, duplicating work the drain already does. Measured
38
+ * instead: the reference workload's messages retain ~273 B each (and encode to
39
+ * ~275 B), so this bound is ~34 MB of queued telemetry. That is the documented
40
+ * ceiling — for *realistic* messages. Attribute values are user-supplied and
41
+ * unbounded, so a program annotating spans with megabyte strings can exceed it;
42
+ * the upgrade path is to track encoded bytes at the `writeBatch` boundary and
43
+ * feed that back as a byte budget, which costs a shared counter and is worth it
44
+ * only once someone actually hits it.
45
+ *
46
+ * 131,072 rather than the old 8,192 because batching the socket writes made the
47
+ * drain ~2.4x faster, so a deeper queue is cheap: it holds the entire 115,310
48
+ * message reference session at once, against a 31,563 msg/s peak that used to
49
+ * overrun 8,192 in a fifth of a second.
50
+ */
51
+ const defaultBufferSize = 131072;
20
52
  /** How often a `Ping` is sent, so the collector can see a quiet program is alive. */
21
53
  const pingInterval = Duration.seconds(3);
22
54
  /** How often `process.memoryUsage()` is sampled, unless told otherwise. */
@@ -28,6 +60,28 @@ const defaultMemoryIntervalMillis = 100;
28
60
  * exit, and that is the worse failure.
29
61
  */
30
62
  const flushTimeout = Duration.millis(250);
63
+ /**
64
+ * How many encoded bytes may ride in one socket frame.
65
+ *
66
+ * The drain writes a whole `takeAll` batch as one newline-delimited frame,
67
+ * which is the point — but a batch is unbounded, and the measured peak second
68
+ * is ~8.5 MB, so one write could otherwise hand the WebSocket a single
69
+ * multi-megabyte buffer to hold and copy. 256 KiB is roughly a thousand
70
+ * messages at the measured ~275 B mean: large enough that the per-write cost
71
+ * this change exists to remove is amortised away, small enough that the
72
+ * transient copy stays a normal allocation rather than a heap spike. The
73
+ * collector splits on newlines across chunk boundaries, so where a frame is
74
+ * cut has no effect on what it parses.
75
+ */
76
+ const maxFrameBytes = 256 * 1024;
77
+ /**
78
+ * How many messages may ride in one socket frame, whatever their size.
79
+ *
80
+ * A second bound for the pathological case the byte budget misses: a batch of
81
+ * very small messages would otherwise build one enormous array of strings
82
+ * before the join.
83
+ */
84
+ const maxFrameMessages = 4096;
31
85
  /** Reconnect backoff: doubling from 250ms, capped so a late collector is still found. */
32
86
  const reconnectSchedule = Schedule.exponential(Duration.millis(250)).pipe(Schedule.modifyDelay(({ output }) => Effect.succeed(Duration.min(output, Duration.seconds(5)))));
33
87
  /**
@@ -38,6 +92,8 @@ const reconnectSchedule = Schedule.exponential(Duration.millis(250)).pipe(Schedu
38
92
  */
39
93
  export class InspectClient extends Context.Service()('effect-inspect/client/InspectClient') {
40
94
  }
95
+ /** Environment variable a launcher sets to choose the session ID. */
96
+ export const sessionIdEnv = 'EFFECT_INSPECT_SESSION_ID';
41
97
  /**
42
98
  * The name this program lists as.
43
99
  *
@@ -74,6 +130,77 @@ const memoryUsage = () => {
74
130
  const usage = globalThis.process?.memoryUsage;
75
131
  return typeof usage === 'function' ? usage.bind(globalThis.process) : undefined;
76
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
+ };
77
204
  /**
78
205
  * Forks the fiber that samples process memory into the outbound queue.
79
206
  *
@@ -112,28 +239,45 @@ const forkMemorySampler = (deps) => Effect.suspend(() => {
112
239
  * The returned effect never fails and never waits for the collector: if it is
113
240
  * unreachable the fiber retries in the background while `sendUnsafe` keeps
114
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.
115
246
  */
116
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();
117
258
  const socket = yield* SocketService.Socket;
118
259
  const capacity = options?.bufferSize ?? defaultBufferSize;
119
- const queue = yield* Queue.sliding(capacity);
120
- // Effect's `Crypto` can fail with a PlatformError and nothing on this path
121
- // is allowed to fail; a session id needs uniqueness, not strength.
122
- // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect
123
- const sessionId = crypto.randomUUID();
260
+ const queue = yield* Queue.dropping(capacity);
124
261
  // Tracked here rather than inside the queue so a drop survives a
125
262
  // reconnect: it is reported on the next `Hello`, which every reconnect
126
- // re-sends. A sliding `offerUnsafe` always succeeds, so a full queue is
127
- // the only drop signal there is.
263
+ // re-sends.
128
264
  // Open exactly while the queue is empty, so shutdown can ask "is everything
129
265
  // on the wire?" rather than guessing with a sleep.
130
266
  const flushed = Latch.makeUnsafe(true);
131
267
  let dropped = 0;
132
268
  let reported = 0;
269
+ // Total and non-blocking by construction, and it has to stay that way: this
270
+ // runs inside `Tracer.span`, `span.end` and a `Logger`, none of which can
271
+ // suspend or fail. A dropping `offerUnsafe` returns `false` when the queue
272
+ // is full instead of suspending, which is the entire reason the queue is
273
+ // dropping rather than a backpressuring `bounded` — refusing a message is a
274
+ // gap in a trace, whereas parking the fiber that emitted it is the traced
275
+ // program running slower because it is being watched.
133
276
  const sendUnsafe = (message) => {
134
- if (Queue.sizeUnsafe(queue) >= capacity)
277
+ if (!Queue.offerUnsafe(queue, message)) {
135
278
  dropped += 1;
136
- Queue.offerUnsafe(queue, message);
279
+ return;
280
+ }
137
281
  flushed.closeUnsafe();
138
282
  };
139
283
  const hello = Effect.clockWith((clock) => Effect.succeed({
@@ -143,6 +287,7 @@ export const make = (options) => Effect.gen(function* () {
143
287
  pid: globalThis.process?.pid ?? 0,
144
288
  runtime: runtimeName(),
145
289
  protocolVersion: Protocol.protocolVersion,
290
+ instanceId,
146
291
  clock: {
147
292
  startTime: clock.currentTimeNanosUnsafe(),
148
293
  // Deliberately the wall clock: this is the anchor that maps the
@@ -196,6 +341,41 @@ const connection = (deps) => Effect.gen(function* () {
196
341
  const reader = yield* deps.socket.reader;
197
342
  const writer = yield* deps.socket.writer;
198
343
  const write = (message) => Effect.suspend(() => writer.write(clientCodec.encode(message)));
344
+ /**
345
+ * Writes a batch as newline-delimited frames instead of one frame each.
346
+ *
347
+ * The codec already terminates every line with `\n`, so a frame is just
348
+ * the concatenation of its lines and the collector's line splitter carries
349
+ * an unterminated remainder across frames — batching needs no protocol
350
+ * change. Frames are cut at {@link maxFrameBytes} / {@link maxFrameMessages}
351
+ * so one huge batch cannot become one unbounded write.
352
+ *
353
+ * `encodeResult` rather than `encode`: `encode` throws, and a throw here
354
+ * would abandon the rest of an already-dequeued batch and tear down the
355
+ * connection over one out-of-domain field. A message that will not encode
356
+ * is dropped on its own and the batch carries on.
357
+ */
358
+ const writeBatch = (batch) => Effect.suspend(() => {
359
+ const frames = [];
360
+ let lines = [];
361
+ let bytes = 0;
362
+ for (const message of batch) {
363
+ const encoded = Result.getOrUndefined(clientCodec.encodeResult(message));
364
+ if (encoded === undefined)
365
+ continue;
366
+ if (lines.length > 0 &&
367
+ (bytes + encoded.length > maxFrameBytes || lines.length >= maxFrameMessages)) {
368
+ frames.push(lines.join(''));
369
+ lines = [];
370
+ bytes = 0;
371
+ }
372
+ lines.push(encoded);
373
+ bytes += encoded.length;
374
+ }
375
+ if (lines.length > 0)
376
+ frames.push(lines.join(''));
377
+ return Effect.forEach(frames, (frame) => writer.write(frame), { discard: true });
378
+ });
199
379
  yield* Effect.flatMap(deps.hello, write);
200
380
  // Keeps the connection warm and surfaces a half-open socket as a write
201
381
  // failure, which is what triggers the reconnect.
@@ -206,7 +386,7 @@ const connection = (deps) => Effect.gen(function* () {
206
386
  const gap = yield* deps.reportDropped;
207
387
  if (gap !== undefined)
208
388
  yield* write(gap);
209
- yield* Effect.forEach(batch, write);
389
+ yield* writeBatch(batch);
210
390
  // Everything offered so far is on the wire. `sendUnsafe` closes the latch
211
391
  // again on the next message, so this tracks the queue rather than latching
212
392
  // permanently on the first quiet moment.
@@ -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>;