effect-inspect 0.1.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 +122 -0
- package/app/dist/client/assets/index-smV05cfr.js +25 -0
- package/app/dist/client/assets/rolldown-runtime-CbXtAM7H.js +1 -0
- package/app/dist/client/assets/routes-TKgeFdSW.js +5 -0
- package/app/dist/client/assets/styles-B3rAZGvS.css +2 -0
- package/app/dist/server/assets/_tanstack-start-manifest_v-Co953HeC.js +20 -0
- package/app/dist/server/assets/empty-plugin-adapters-D9UWiqvJ.js +5 -0
- package/app/dist/server/assets/router-CN98Ramo.js +491 -0
- package/app/dist/server/assets/routes-eZ4XqxE9.js +3999 -0
- package/app/dist/server/assets/start-5Z2QO8AU.js +4 -0
- package/app/dist/server/server.js +1812 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.js +29 -0
- package/dist/client/Client.d.ts +52 -0
- package/dist/client/Client.js +224 -0
- package/dist/client/Edge.d.ts +31 -0
- package/dist/client/Edge.js +108 -0
- package/dist/client/Inspect.d.ts +49 -0
- package/dist/client/Inspect.js +55 -0
- package/dist/client/Tracer.d.ts +31 -0
- package/dist/client/Tracer.js +119 -0
- package/dist/collector/Config.d.ts +8 -0
- package/dist/collector/Config.js +9 -0
- package/dist/collector/Server.d.ts +24 -0
- package/dist/collector/Server.js +172 -0
- package/dist/collector/Store.d.ts +86 -0
- package/dist/collector/Store.js +119 -0
- package/dist/collector/WebApp.d.ts +3 -0
- package/dist/collector/WebApp.js +36 -0
- package/dist/collector/main.d.ts +1 -0
- package/dist/collector/main.js +22 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -0
- package/dist/protocol/Codec.d.ts +575 -0
- package/dist/protocol/Codec.js +50 -0
- package/dist/protocol/Schema.d.ts +1237 -0
- package/dist/protocol/Schema.js +327 -0
- package/package.json +85 -0
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import * as NodeHttpServer from '@effect/platform-node/NodeHttpServer';
|
|
3
|
+
import * as NodeRuntime from '@effect/platform-node/NodeRuntime';
|
|
4
|
+
import * as NodeServices from '@effect/platform-node/NodeServices';
|
|
5
|
+
// NodeHttpServer needs a native server constructor to own the HTTP and upgrade listeners.
|
|
6
|
+
// oxlint-disable-next-line effecttsgo/node-builtin-import
|
|
7
|
+
import { createServer } from 'node:http';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { Effect, Layer, Schema } from 'effect';
|
|
10
|
+
import { FileSystem } from 'effect/FileSystem';
|
|
11
|
+
import { Command } from 'effect/unstable/cli';
|
|
12
|
+
import { collectorConfig } from './collector/Config.js';
|
|
13
|
+
import { run } from './collector/Server.js';
|
|
14
|
+
import { layer as storeLayer } from './collector/Store.js';
|
|
15
|
+
import { loadWebApp } from './collector/WebApp.js';
|
|
16
|
+
const start = Command.make('start', {}, () => Effect.gen(function* () {
|
|
17
|
+
const { capacity, port } = yield* collectorConfig;
|
|
18
|
+
const fetch = yield* loadWebApp;
|
|
19
|
+
yield* Effect.logInfo(`effect-inspect listening at http://localhost:${port}`);
|
|
20
|
+
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]));
|
|
23
|
+
const main = Effect.gen(function* () {
|
|
24
|
+
const fs = yield* FileSystem;
|
|
25
|
+
const packageJson = yield* fs.readFileString(fileURLToPath(new URL('../package.json', import.meta.url)));
|
|
26
|
+
const { version } = yield* Schema.decodeEffect(Schema.fromJsonString(Schema.Struct({ version: Schema.String })))(packageJson);
|
|
27
|
+
return yield* Command.run(cli, { version });
|
|
28
|
+
});
|
|
29
|
+
NodeRuntime.runMain(main.pipe(Effect.scoped, Effect.provide(NodeServices.layer)));
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound half of the inspect layer: a bounded queue plus the fiber that
|
|
3
|
+
* drains it to the collector.
|
|
4
|
+
*
|
|
5
|
+
* The guarantee that shapes this module is that instrumenting a program must
|
|
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
|
|
8
|
+
* concern — no collector listening, a mid-run disconnect, a consumer slower
|
|
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
|
|
12
|
+
* or pushing backpressure into the fibers being traced.
|
|
13
|
+
*/
|
|
14
|
+
import { Context, Effect, type Scope } from 'effect';
|
|
15
|
+
import type { Socket } from 'effect/unstable/socket';
|
|
16
|
+
import * as Protocol from '../protocol/Schema.ts';
|
|
17
|
+
declare const InspectClient_base: Context.ServiceClass<InspectClient, "effect-inspect/client/InspectClient", {
|
|
18
|
+
readonly sessionId: Protocol.SessionId;
|
|
19
|
+
readonly sendUnsafe: (message: Protocol.ClientMessage) => void;
|
|
20
|
+
}>;
|
|
21
|
+
/**
|
|
22
|
+
* The sink the tracer and logger push telemetry into.
|
|
23
|
+
*
|
|
24
|
+
* `sendUnsafe` is deliberately synchronous and total: it is called from inside
|
|
25
|
+
* `Tracer.span`, `span.end` and a `Logger`, none of which can suspend or fail.
|
|
26
|
+
*/
|
|
27
|
+
export declare class InspectClient extends InspectClient_base {
|
|
28
|
+
}
|
|
29
|
+
/** Options accepted by the inspect layers. */
|
|
30
|
+
export interface Options {
|
|
31
|
+
/** Name shown for this program in the webapp. Defaults to the entry script's file name. */
|
|
32
|
+
readonly programName?: string | undefined;
|
|
33
|
+
/** Outbound queue capacity, in messages. Defaults to 8192. */
|
|
34
|
+
readonly bufferSize?: number | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* How often to sample `process.memoryUsage()`, in milliseconds. Defaults to
|
|
37
|
+
* 100. Set to `0` to record no memory at all.
|
|
38
|
+
*
|
|
39
|
+
* Ignored in a runtime without `process.memoryUsage` — a browser or an edge
|
|
40
|
+
* worker records nothing and says nothing about it.
|
|
41
|
+
*/
|
|
42
|
+
readonly memoryIntervalMillis?: number | undefined;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Builds the client service and forks the fiber that owns the connection.
|
|
46
|
+
*
|
|
47
|
+
* The returned effect never fails and never waits for the collector: if it is
|
|
48
|
+
* unreachable the fiber retries in the background while `sendUnsafe` keeps
|
|
49
|
+
* accepting (and dropping) messages, so the host program is unaffected.
|
|
50
|
+
*/
|
|
51
|
+
export declare const make: (options?: Options) => Effect.Effect<InspectClient['Service'], never, Scope.Scope | Socket.Socket>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outbound half of the inspect layer: a bounded queue plus the fiber that
|
|
3
|
+
* drains it to the collector.
|
|
4
|
+
*
|
|
5
|
+
* The guarantee that shapes this module is that instrumenting a program must
|
|
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
|
|
8
|
+
* concern — no collector listening, a mid-run disconnect, a consumer slower
|
|
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
|
|
12
|
+
* or pushing backpressure into the fibers being traced.
|
|
13
|
+
*/
|
|
14
|
+
import { Context, Duration, Effect, Latch, Queue, Schedule } from 'effect';
|
|
15
|
+
import { Socket as SocketService } from 'effect/unstable/socket';
|
|
16
|
+
import { clientCodec } from '../protocol/Codec.js';
|
|
17
|
+
import * as Protocol from '../protocol/Schema.js';
|
|
18
|
+
/** How many messages may be buffered before the oldest are dropped. */
|
|
19
|
+
const defaultBufferSize = 8192;
|
|
20
|
+
/** How often a `Ping` is sent, so the collector can see a quiet program is alive. */
|
|
21
|
+
const pingInterval = Duration.seconds(3);
|
|
22
|
+
/** How often `process.memoryUsage()` is sampled, unless told otherwise. */
|
|
23
|
+
const defaultMemoryIntervalMillis = 100;
|
|
24
|
+
/**
|
|
25
|
+
* How long shutdown waits for queued messages to reach the collector.
|
|
26
|
+
*
|
|
27
|
+
* Bounded because the alternative to a lost tail is a program that will not
|
|
28
|
+
* exit, and that is the worse failure.
|
|
29
|
+
*/
|
|
30
|
+
const flushTimeout = Duration.millis(250);
|
|
31
|
+
/** Reconnect backoff: doubling from 250ms, capped so a late collector is still found. */
|
|
32
|
+
const reconnectSchedule = Schedule.exponential(Duration.millis(250)).pipe(Schedule.modifyDelay(({ output }) => Effect.succeed(Duration.min(output, Duration.seconds(5)))));
|
|
33
|
+
/**
|
|
34
|
+
* The sink the tracer and logger push telemetry into.
|
|
35
|
+
*
|
|
36
|
+
* `sendUnsafe` is deliberately synchronous and total: it is called from inside
|
|
37
|
+
* `Tracer.span`, `span.end` and a `Logger`, none of which can suspend or fail.
|
|
38
|
+
*/
|
|
39
|
+
export class InspectClient extends Context.Service()('effect-inspect/client/InspectClient') {
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* The name this program lists as.
|
|
43
|
+
*
|
|
44
|
+
* The entry script's **file name**, not its path: `argv[1]` is absolute, so the
|
|
45
|
+
* default used to list a program as `/Users/.../examples/webapp.ts` and fill
|
|
46
|
+
* the session list with the same prefix over and over.
|
|
47
|
+
*/
|
|
48
|
+
const programName = (options) => {
|
|
49
|
+
if (options?.programName !== undefined)
|
|
50
|
+
return options.programName;
|
|
51
|
+
const script = globalThis.process?.argv?.[1];
|
|
52
|
+
if (script === undefined || script === '')
|
|
53
|
+
return 'effect';
|
|
54
|
+
return script.split(/[/\\]/).pop() || script;
|
|
55
|
+
};
|
|
56
|
+
const runtimeName = () => {
|
|
57
|
+
const versions = globalThis.process?.versions;
|
|
58
|
+
if (versions?.bun !== undefined)
|
|
59
|
+
return `bun ${versions.bun}`;
|
|
60
|
+
if (versions?.node !== undefined)
|
|
61
|
+
return `node ${versions.node}`;
|
|
62
|
+
return 'unknown';
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* `process.memoryUsage`, or `undefined` in a runtime that has no such thing.
|
|
66
|
+
*
|
|
67
|
+
* Resolved once, at layer construction: the check is a property read, but doing
|
|
68
|
+
* it per sample would be a property read on the host program's hot path for a
|
|
69
|
+
* value that cannot change. A browser, Deno without `--allow-*`, or a Workers
|
|
70
|
+
* runtime lands on `undefined` and simply never samples — silently, forever,
|
|
71
|
+
* which is the whole contract here.
|
|
72
|
+
*/
|
|
73
|
+
const memoryUsage = () => {
|
|
74
|
+
const usage = globalThis.process?.memoryUsage;
|
|
75
|
+
return typeof usage === 'function' ? usage.bind(globalThis.process) : undefined;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* Forks the fiber that samples process memory into the outbound queue.
|
|
79
|
+
*
|
|
80
|
+
* Deliberately the same shape as the `Ping` fiber: a delayed `forever` forked
|
|
81
|
+
* into the client's scope, so it dies with the layer and cannot keep a program
|
|
82
|
+
* alive past its own exit. Sampling rides `sendUnsafe` like everything else, so
|
|
83
|
+
* a full queue drops a sample rather than pushing back on the host — a gap in a
|
|
84
|
+
* memory curve is a far cheaper failure than a stalled program.
|
|
85
|
+
*
|
|
86
|
+
* Returns `void` and forks nothing when the runtime has no `process.memoryUsage`
|
|
87
|
+
* or the interval is not a positive number.
|
|
88
|
+
*/
|
|
89
|
+
const forkMemorySampler = (deps) => Effect.suspend(() => {
|
|
90
|
+
const usage = memoryUsage();
|
|
91
|
+
if (usage === undefined || !(deps.intervalMillis > 0))
|
|
92
|
+
return Effect.void;
|
|
93
|
+
const sample = Effect.clockWith((clock) => Effect.sync(() => {
|
|
94
|
+
const memory = usage();
|
|
95
|
+
deps.sendUnsafe({
|
|
96
|
+
_tag: 'MemorySample',
|
|
97
|
+
sessionId: deps.sessionId,
|
|
98
|
+
time: clock.currentTimeNanosUnsafe(),
|
|
99
|
+
// Rounded because the protocol's `Natural` rejects a fraction, and
|
|
100
|
+
// `rss` on some platforms is not an integer.
|
|
101
|
+
heapUsed: Math.round(memory.heapUsed),
|
|
102
|
+
heapTotal: Math.round(memory.heapTotal),
|
|
103
|
+
rss: Math.round(memory.rss),
|
|
104
|
+
external: Math.round(memory.external),
|
|
105
|
+
});
|
|
106
|
+
}));
|
|
107
|
+
return sample.pipe(Effect.delay(Duration.millis(deps.intervalMillis)), Effect.forever, Effect.forkScoped, Effect.asVoid);
|
|
108
|
+
});
|
|
109
|
+
/**
|
|
110
|
+
* Builds the client service and forks the fiber that owns the connection.
|
|
111
|
+
*
|
|
112
|
+
* The returned effect never fails and never waits for the collector: if it is
|
|
113
|
+
* unreachable the fiber retries in the background while `sendUnsafe` keeps
|
|
114
|
+
* accepting (and dropping) messages, so the host program is unaffected.
|
|
115
|
+
*/
|
|
116
|
+
export const make = (options) => Effect.gen(function* () {
|
|
117
|
+
const socket = yield* SocketService.Socket;
|
|
118
|
+
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();
|
|
124
|
+
// Tracked here rather than inside the queue so a drop survives a
|
|
125
|
+
// 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.
|
|
128
|
+
// Open exactly while the queue is empty, so shutdown can ask "is everything
|
|
129
|
+
// on the wire?" rather than guessing with a sleep.
|
|
130
|
+
const flushed = Latch.makeUnsafe(true);
|
|
131
|
+
let dropped = 0;
|
|
132
|
+
let reported = 0;
|
|
133
|
+
const sendUnsafe = (message) => {
|
|
134
|
+
if (Queue.sizeUnsafe(queue) >= capacity)
|
|
135
|
+
dropped += 1;
|
|
136
|
+
Queue.offerUnsafe(queue, message);
|
|
137
|
+
flushed.closeUnsafe();
|
|
138
|
+
};
|
|
139
|
+
const hello = Effect.clockWith((clock) => Effect.succeed({
|
|
140
|
+
_tag: 'Hello',
|
|
141
|
+
sessionId,
|
|
142
|
+
program: programName(options),
|
|
143
|
+
pid: globalThis.process?.pid ?? 0,
|
|
144
|
+
runtime: runtimeName(),
|
|
145
|
+
protocolVersion: Protocol.protocolVersion,
|
|
146
|
+
clock: {
|
|
147
|
+
startTime: clock.currentTimeNanosUnsafe(),
|
|
148
|
+
// Deliberately the wall clock: this is the anchor that maps the
|
|
149
|
+
// monotonic span clock onto absolute time, which is precisely what
|
|
150
|
+
// Effect's `Clock` is not.
|
|
151
|
+
// oxlint-disable-next-line effecttsgo/global-date
|
|
152
|
+
wallClockEpochMillis: Date.now(),
|
|
153
|
+
},
|
|
154
|
+
}));
|
|
155
|
+
// ponytail: drops are reported as a `Log`, not a dedicated protocol field.
|
|
156
|
+
// `Hello` has no dropped-count and the schema is frozen; a Warn log rides
|
|
157
|
+
// the same ordered stream the webapp already renders, so the gap is visible
|
|
158
|
+
// in place. Promote to a real field if the UI needs to count it separately.
|
|
159
|
+
const reportDropped = Effect.clockWith((clock) => Effect.sync(() => {
|
|
160
|
+
if (dropped === reported)
|
|
161
|
+
return undefined;
|
|
162
|
+
const gap = dropped - reported;
|
|
163
|
+
reported = dropped;
|
|
164
|
+
return {
|
|
165
|
+
_tag: 'Log',
|
|
166
|
+
sessionId,
|
|
167
|
+
time: clock.currentTimeNanosUnsafe(),
|
|
168
|
+
level: 'Warn',
|
|
169
|
+
message: `effect-inspect dropped ${gap} messages: the collector is slower than this program`,
|
|
170
|
+
annotations: { 'effect_inspect.dropped': gap, 'effect_inspect.droppedTotal': dropped },
|
|
171
|
+
};
|
|
172
|
+
}));
|
|
173
|
+
yield* connection({ socket, queue, sessionId, hello, reportDropped, flushed }).pipe(Effect.forkScoped);
|
|
174
|
+
yield* forkMemorySampler({
|
|
175
|
+
sendUnsafe,
|
|
176
|
+
sessionId,
|
|
177
|
+
intervalMillis: options?.memoryIntervalMillis ?? defaultMemoryIntervalMillis,
|
|
178
|
+
});
|
|
179
|
+
// A short program can finish before the socket has even opened, and closing
|
|
180
|
+
// the scope would otherwise interrupt the connection fiber mid-flight and
|
|
181
|
+
// lose the whole trace. So wait for the queue to drain — but only briefly,
|
|
182
|
+
// and never propagating a failure: a collector that has gone away must not
|
|
183
|
+
// become a program that will not exit.
|
|
184
|
+
yield* Effect.addFinalizer(() => Latch.await(flushed).pipe(Effect.timeoutOption(flushTimeout), Effect.ignore));
|
|
185
|
+
return InspectClient.of({ sessionId, sendUnsafe });
|
|
186
|
+
});
|
|
187
|
+
/**
|
|
188
|
+
* Holds one connection open: sends `Hello`, then pumps the queue until the
|
|
189
|
+
* socket fails, at which point the retry schedule dials again.
|
|
190
|
+
*/
|
|
191
|
+
const connection = (deps) => Effect.gen(function* () {
|
|
192
|
+
// The reader is what actually dials: a WebSocket `Socket` only connects
|
|
193
|
+
// when its reader is acquired, and until then every write parks waiting for
|
|
194
|
+
// a connection that will never be made. Acquiring it also surfaces the
|
|
195
|
+
// disconnect that drives the reconnect below.
|
|
196
|
+
const reader = yield* deps.socket.reader;
|
|
197
|
+
const writer = yield* deps.socket.writer;
|
|
198
|
+
const write = (message) => Effect.suspend(() => writer.write(clientCodec.encode(message)));
|
|
199
|
+
yield* Effect.flatMap(deps.hello, write);
|
|
200
|
+
// Keeps the connection warm and surfaces a half-open socket as a write
|
|
201
|
+
// failure, which is what triggers the reconnect.
|
|
202
|
+
yield* write({ _tag: 'Ping', sessionId: deps.sessionId }).pipe(Effect.delay(pingInterval), Effect.forever, Effect.forkScoped);
|
|
203
|
+
const pump = Effect.gen(function* () {
|
|
204
|
+
const batch = yield* Queue.takeAll(deps.queue);
|
|
205
|
+
// Announced before the batch, so the gap is ordered where it happened.
|
|
206
|
+
const gap = yield* deps.reportDropped;
|
|
207
|
+
if (gap !== undefined)
|
|
208
|
+
yield* write(gap);
|
|
209
|
+
yield* Effect.forEach(batch, write);
|
|
210
|
+
// Everything offered so far is on the wire. `sendUnsafe` closes the latch
|
|
211
|
+
// again on the next message, so this tracks the queue rather than latching
|
|
212
|
+
// permanently on the first quiet moment.
|
|
213
|
+
if (Queue.sizeUnsafe(deps.queue) === 0)
|
|
214
|
+
deps.flushed.openUnsafe();
|
|
215
|
+
});
|
|
216
|
+
// Raced rather than forked: a forked fiber's failure would not reach this
|
|
217
|
+
// scope, so a collector that goes away mid-run would be noticed only if the
|
|
218
|
+
// program happened to write again. Whichever side notices the disconnect
|
|
219
|
+
// first ends the connection, and the retry below dials the next one.
|
|
220
|
+
return yield* Effect.raceFirst(Effect.forever(pump), Effect.forever(reader.pull));
|
|
221
|
+
}).pipe(Effect.scoped,
|
|
222
|
+
// Every failure here is the collector's, never the host program's: retry
|
|
223
|
+
// forever, and if the retry itself somehow gives up, stay silent.
|
|
224
|
+
Effect.retry(reconnectSchedule), Effect.ignore);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge conversions from Effect's runtime values to the wire protocol's bounded
|
|
3
|
+
* domain.
|
|
4
|
+
*
|
|
5
|
+
* The protocol deliberately refuses anything it cannot render: attribute values
|
|
6
|
+
* are a bounded {@link Protocol.Json} union, and `SpanEnd` carries a flattened
|
|
7
|
+
* outcome rather than a `Cause`. Both narrowings happen here, at the producer,
|
|
8
|
+
* so a decoder never has to guess and an exotic attribute value can never fail
|
|
9
|
+
* an encode mid-flight.
|
|
10
|
+
*/
|
|
11
|
+
import { type Exit } from 'effect';
|
|
12
|
+
import type * as Protocol from '../protocol/Schema.ts';
|
|
13
|
+
/**
|
|
14
|
+
* Coerces an arbitrary runtime value into the protocol's `Json` union.
|
|
15
|
+
*
|
|
16
|
+
* Everything outside the union — functions, symbols, bigints, class instances,
|
|
17
|
+
* `undefined`, and the non-finite numbers the codec rejects — is stringified.
|
|
18
|
+
* Cycles are replaced with `"[Circular]"` rather than throwing.
|
|
19
|
+
*/
|
|
20
|
+
export declare const toJson: (value: unknown) => Protocol.Json;
|
|
21
|
+
/** Coerces a map or record of attributes into the protocol's bounded domain. */
|
|
22
|
+
export declare const toAttributes: (entries: ReadonlyMap<string, unknown> | Readonly<Record<string, unknown>>) => Protocol.Attributes;
|
|
23
|
+
/**
|
|
24
|
+
* Flattens an `Exit` into the protocol's `SpanOutcome`.
|
|
25
|
+
*
|
|
26
|
+
* The whole `Cause` is collapsed to a kind plus a rendered message: the webapp
|
|
27
|
+
* only needs to colour the span and show text. A cause carrying several reasons
|
|
28
|
+
* is classified by its most informative one — a real failure or defect outranks
|
|
29
|
+
* an interrupt, since interrupting siblings is how Effect unwinds a failure.
|
|
30
|
+
*/
|
|
31
|
+
export declare const toOutcome: (exit: Exit.Exit<unknown, unknown>) => Protocol.SpanOutcome;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Edge conversions from Effect's runtime values to the wire protocol's bounded
|
|
3
|
+
* domain.
|
|
4
|
+
*
|
|
5
|
+
* The protocol deliberately refuses anything it cannot render: attribute values
|
|
6
|
+
* are a bounded {@link Protocol.Json} union, and `SpanEnd` carries a flattened
|
|
7
|
+
* outcome rather than a `Cause`. Both narrowings happen here, at the producer,
|
|
8
|
+
* so a decoder never has to guess and an exotic attribute value can never fail
|
|
9
|
+
* an encode mid-flight.
|
|
10
|
+
*/
|
|
11
|
+
import { Cause } from 'effect';
|
|
12
|
+
/**
|
|
13
|
+
* Coerces an arbitrary runtime value into the protocol's `Json` union.
|
|
14
|
+
*
|
|
15
|
+
* Everything outside the union — functions, symbols, bigints, class instances,
|
|
16
|
+
* `undefined`, and the non-finite numbers the codec rejects — is stringified.
|
|
17
|
+
* Cycles are replaced with `"[Circular]"` rather than throwing.
|
|
18
|
+
*/
|
|
19
|
+
export const toJson = (value) => jsonWithin(value, new Set());
|
|
20
|
+
const jsonWithin = (value, seen) => {
|
|
21
|
+
switch (typeof value) {
|
|
22
|
+
case 'string':
|
|
23
|
+
case 'boolean':
|
|
24
|
+
return value;
|
|
25
|
+
// `Infinity`/`NaN` are rejected by the codec, so they become text here.
|
|
26
|
+
case 'number':
|
|
27
|
+
return Number.isFinite(value) ? value : String(value);
|
|
28
|
+
case 'bigint':
|
|
29
|
+
return String(value);
|
|
30
|
+
case 'object':
|
|
31
|
+
break;
|
|
32
|
+
// function, symbol, undefined
|
|
33
|
+
default:
|
|
34
|
+
return stringify(value);
|
|
35
|
+
}
|
|
36
|
+
if (value === null)
|
|
37
|
+
return null;
|
|
38
|
+
if (seen.has(value))
|
|
39
|
+
return '[Circular]';
|
|
40
|
+
seen.add(value);
|
|
41
|
+
try {
|
|
42
|
+
if (Array.isArray(value))
|
|
43
|
+
return value.map((item) => jsonWithin(item, seen));
|
|
44
|
+
if (isPlainObject(value)) {
|
|
45
|
+
const out = {};
|
|
46
|
+
for (const [key, item] of Object.entries(value))
|
|
47
|
+
out[key] = jsonWithin(item, seen);
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
// Dates, Errors, Maps, class instances: rendered, not structurally walked.
|
|
51
|
+
return stringify(value);
|
|
52
|
+
}
|
|
53
|
+
finally {
|
|
54
|
+
seen.delete(value);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
const isPlainObject = (value) => {
|
|
58
|
+
const proto = Object.getPrototypeOf(value);
|
|
59
|
+
return proto === Object.prototype || proto === null;
|
|
60
|
+
};
|
|
61
|
+
const stringify = (value) => {
|
|
62
|
+
if (value === undefined)
|
|
63
|
+
return 'undefined';
|
|
64
|
+
if (typeof value === 'symbol' || typeof value === 'function')
|
|
65
|
+
return String(value);
|
|
66
|
+
try {
|
|
67
|
+
// `[object Object]` is an acceptable last resort; the alternative is
|
|
68
|
+
// dropping an attribute the user asked to see.
|
|
69
|
+
// oxlint-disable-next-line no-base-to-string
|
|
70
|
+
return value instanceof Error ? `${value.name}: ${value.message}` : String(value);
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return '[unrenderable]';
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
/** Coerces a map or record of attributes into the protocol's bounded domain. */
|
|
77
|
+
export const toAttributes = (entries) => {
|
|
78
|
+
const out = {};
|
|
79
|
+
const pairs = entries instanceof Map ? entries.entries() : Object.entries(entries);
|
|
80
|
+
for (const [key, value] of pairs)
|
|
81
|
+
out[key] = toJson(value);
|
|
82
|
+
return out;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Flattens an `Exit` into the protocol's `SpanOutcome`.
|
|
86
|
+
*
|
|
87
|
+
* The whole `Cause` is collapsed to a kind plus a rendered message: the webapp
|
|
88
|
+
* only needs to colour the span and show text. A cause carrying several reasons
|
|
89
|
+
* is classified by its most informative one — a real failure or defect outranks
|
|
90
|
+
* an interrupt, since interrupting siblings is how Effect unwinds a failure.
|
|
91
|
+
*/
|
|
92
|
+
export const toOutcome = (exit) => {
|
|
93
|
+
if (exit._tag === 'Success')
|
|
94
|
+
return { _tag: 'Success' };
|
|
95
|
+
const cause = exit.cause;
|
|
96
|
+
const reason = cause.reasons.find((candidate) => !Cause.isInterruptReason(candidate)) ?? cause.reasons[0];
|
|
97
|
+
if (reason === undefined)
|
|
98
|
+
return { _tag: 'Failure', kind: 'Interrupt', error: 'Interrupted' };
|
|
99
|
+
const kind = reason._tag;
|
|
100
|
+
const stack = stackOf(Cause.isFailReason(reason) ? reason.error : undefined);
|
|
101
|
+
return {
|
|
102
|
+
_tag: 'Failure',
|
|
103
|
+
kind,
|
|
104
|
+
error: Cause.pretty(cause),
|
|
105
|
+
...(stack === undefined ? {} : { stack }),
|
|
106
|
+
};
|
|
107
|
+
};
|
|
108
|
+
const stackOf = (error) => error instanceof Error && typeof error.stack === 'string' ? error.stack : undefined;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layer a user adds to an Effect program to stream it to effect-inspect.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { Effect } from 'effect'
|
|
6
|
+
* import { Inspect } from 'effect-inspect'
|
|
7
|
+
*
|
|
8
|
+
* program.pipe(Effect.provide(Inspect.layer()))
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Adding this layer is safe in any environment: if no collector is listening
|
|
12
|
+
* the program runs exactly as it would have, without hanging, erroring, or
|
|
13
|
+
* waiting on a connection. See `Client.ts` for how that is kept true.
|
|
14
|
+
*/
|
|
15
|
+
import { Layer } from 'effect';
|
|
16
|
+
import { Socket } from 'effect/unstable/socket';
|
|
17
|
+
import * as Client from './Client.ts';
|
|
18
|
+
export type { Options } from './Client.ts';
|
|
19
|
+
export { InspectClient } from './Client.ts';
|
|
20
|
+
/** Where the collector listens unless told otherwise. */
|
|
21
|
+
export declare const defaultUrl = "ws://localhost:34437";
|
|
22
|
+
/**
|
|
23
|
+
* Installs the inspect tracer and logger over an existing `Socket`.
|
|
24
|
+
*
|
|
25
|
+
* Use when the transport is already established — a test harness, or a
|
|
26
|
+
* collector reached over something other than a WebSocket.
|
|
27
|
+
*
|
|
28
|
+
* The logger is merged into the program's existing loggers rather than
|
|
29
|
+
* replacing them, so console output is untouched.
|
|
30
|
+
*/
|
|
31
|
+
export declare const layerSocket: (options?: Client.Options) => Layer.Layer<never, never, Socket.Socket>;
|
|
32
|
+
/**
|
|
33
|
+
* Installs the inspect tracer and logger over a WebSocket to `url`.
|
|
34
|
+
*
|
|
35
|
+
* Requires a `Socket.WebSocketConstructor`; {@link layer} is the version that
|
|
36
|
+
* supplies the global one for you.
|
|
37
|
+
*/
|
|
38
|
+
export declare const layerWebSocket: (options?: Client.Options & {
|
|
39
|
+
readonly url?: string;
|
|
40
|
+
}) => Layer.Layer<never, never, Socket.WebSocketConstructor>;
|
|
41
|
+
/**
|
|
42
|
+
* Installs the inspect tracer and logger over a WebSocket, using the runtime's
|
|
43
|
+
* global `WebSocket`.
|
|
44
|
+
*
|
|
45
|
+
* This is the entry point most programs want.
|
|
46
|
+
*/
|
|
47
|
+
export declare const layer: (options?: Client.Options & {
|
|
48
|
+
readonly url?: string;
|
|
49
|
+
}) => Layer.Layer<never>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The layer a user adds to an Effect program to stream it to effect-inspect.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { Effect } from 'effect'
|
|
6
|
+
* import { Inspect } from 'effect-inspect'
|
|
7
|
+
*
|
|
8
|
+
* program.pipe(Effect.provide(Inspect.layer()))
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Adding this layer is safe in any environment: if no collector is listening
|
|
12
|
+
* the program runs exactly as it would have, without hanging, erroring, or
|
|
13
|
+
* waiting on a connection. See `Client.ts` for how that is kept true.
|
|
14
|
+
*/
|
|
15
|
+
import { Layer, Logger, Tracer } from 'effect';
|
|
16
|
+
import { Socket } from 'effect/unstable/socket';
|
|
17
|
+
import * as Client from './Client.js';
|
|
18
|
+
import * as ClientTracer from './Tracer.js';
|
|
19
|
+
export { InspectClient } from './Client.js';
|
|
20
|
+
// ponytail: no `FiberEvent` is emitted. The only public hook is
|
|
21
|
+
// `Metric.FiberRuntimeMetricsService`, whose `recordFiberStart`/`recordFiberEnd`
|
|
22
|
+
// receive a `Context` and an `Exit` but no fiber id — and it has nothing for
|
|
23
|
+
// Suspend/Resume at all. Emitting the protocol's `FiberEvent` from it would
|
|
24
|
+
// mean patching runtime internals for data M1 does not render anyway. Revisit
|
|
25
|
+
// when Effect exposes fiber ids on that service.
|
|
26
|
+
/** Where the collector listens unless told otherwise. */
|
|
27
|
+
export const defaultUrl = 'ws://localhost:34437';
|
|
28
|
+
/** Provides the queue and connection the tracer and logger both write into. */
|
|
29
|
+
const layerClient = (options) => Layer.effect(Client.InspectClient)(Client.make(options));
|
|
30
|
+
/**
|
|
31
|
+
* Installs the inspect tracer and logger over an existing `Socket`.
|
|
32
|
+
*
|
|
33
|
+
* Use when the transport is already established — a test harness, or a
|
|
34
|
+
* collector reached over something other than a WebSocket.
|
|
35
|
+
*
|
|
36
|
+
* The logger is merged into the program's existing loggers rather than
|
|
37
|
+
* replacing them, so console output is untouched.
|
|
38
|
+
*/
|
|
39
|
+
export const layerSocket = (options) => Layer.merge(Layer.effect(Tracer.Tracer)(Client.InspectClient.use(ClientTracer.make)), Logger.layer([Client.InspectClient.useSync(ClientTracer.makeLogger)], {
|
|
40
|
+
mergeWithExisting: true,
|
|
41
|
+
})).pipe(Layer.provide(layerClient(options)));
|
|
42
|
+
/**
|
|
43
|
+
* Installs the inspect tracer and logger over a WebSocket to `url`.
|
|
44
|
+
*
|
|
45
|
+
* Requires a `Socket.WebSocketConstructor`; {@link layer} is the version that
|
|
46
|
+
* supplies the global one for you.
|
|
47
|
+
*/
|
|
48
|
+
export const layerWebSocket = (options) => layerSocket(options).pipe(Layer.provide(Socket.layerWebSocket(options?.url ?? defaultUrl)));
|
|
49
|
+
/**
|
|
50
|
+
* Installs the inspect tracer and logger over a WebSocket, using the runtime's
|
|
51
|
+
* global `WebSocket`.
|
|
52
|
+
*
|
|
53
|
+
* This is the entry point most programs want.
|
|
54
|
+
*/
|
|
55
|
+
export const layer = (options) => layerWebSocket(options).pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal));
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The tracer and logger that observe the host program.
|
|
3
|
+
*
|
|
4
|
+
* Both delegate rather than replace: the tracer wraps whatever tracer is
|
|
5
|
+
* already installed and the logger is merged into the existing set, so adding
|
|
6
|
+
* the inspect layer never removes behaviour the program already had. Every
|
|
7
|
+
* method here is called on the host program's own fibers, so none of them may
|
|
8
|
+
* fail, suspend, or do real work — they narrow a value to the protocol's domain
|
|
9
|
+
* and hand it to a queue.
|
|
10
|
+
*/
|
|
11
|
+
import { Effect, Logger, Tracer } from 'effect';
|
|
12
|
+
import type { InspectClient } from './Client.ts';
|
|
13
|
+
/**
|
|
14
|
+
* Creates a tracer that mirrors every span to the client while delegating to
|
|
15
|
+
* the current tracer.
|
|
16
|
+
*
|
|
17
|
+
* `SpanStart` is sent once when the span opens and `SpanEnd` when it closes,
|
|
18
|
+
* carrying the end time, the flattened outcome, and the span's attributes for
|
|
19
|
+
* the collector to merge. See `span` for why the attributes are not a strict
|
|
20
|
+
* delta.
|
|
21
|
+
*/
|
|
22
|
+
export declare const make: (client: InspectClient['Service']) => Effect.Effect<Tracer.Tracer>;
|
|
23
|
+
/**
|
|
24
|
+
* Creates a logger that mirrors every log record to the client, correlated with
|
|
25
|
+
* the span the logging fiber is inside.
|
|
26
|
+
*
|
|
27
|
+
* Effect's own tracer logger turns logs into span events, which loses logs
|
|
28
|
+
* emitted outside any span. These are sent as `Log` instead, so they keep their
|
|
29
|
+
* level and appear in the stream whether or not a span was active.
|
|
30
|
+
*/
|
|
31
|
+
export declare const makeLogger: (client: InspectClient['Service']) => Logger.Logger<unknown, void>;
|