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
package/dist/cli.d.ts
CHANGED
|
@@ -1,3 +1,2 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
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
|
|
12
|
-
import {
|
|
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.
|
|
22
|
-
|
|
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
|
-
|
|
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)));
|
package/dist/client/Client.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/client/Client.js
CHANGED
|
@@ -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>;
|
package/dist/collector/Server.js
CHANGED
|
@@ -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
|
|
93
|
-
|
|
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
|
|
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(
|
|
172
|
+
const response = yield* Effect.promise(() => fetch(new Request(url.href, {
|
|
166
173
|
method: request.method,
|
|
167
174
|
headers: request.headers,
|
|
168
175
|
})));
|