effect-inspect 0.1.0 → 0.1.1
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
CHANGED
|
@@ -66,7 +66,7 @@ Options, all optional:
|
|
|
66
66
|
Inspect.layer({
|
|
67
67
|
url: 'ws://localhost:34437', // where the collector listens
|
|
68
68
|
programName: 'my-service', // what the session list shows; defaults to the entry script's file name
|
|
69
|
-
bufferSize:
|
|
69
|
+
bufferSize: 131072, // outbound queue, in messages (~34 MB at the measured mean)
|
|
70
70
|
})
|
|
71
71
|
```
|
|
72
72
|
|
|
@@ -89,9 +89,10 @@ someone else.
|
|
|
89
89
|
**Adding the layer is safe anywhere.** If no collector is listening, the program
|
|
90
90
|
runs exactly as it would have — no hang, no error, no delay. If the collector
|
|
91
91
|
goes away mid-run the program keeps going and reconnects in the background,
|
|
92
|
-
resuming the same session. If you outrun the socket, the
|
|
93
|
-
|
|
94
|
-
backpressure into your fibers
|
|
92
|
+
resuming the same session. If you outrun the socket, the buffer refuses the
|
|
93
|
+
newest messages once it is full and reports the gap as a warning in the trace,
|
|
94
|
+
rather than pushing backpressure into your fibers — so what you lose is the tail
|
|
95
|
+
of a burst, never a span's start or end that already made it into the buffer.
|
|
95
96
|
|
|
96
97
|
The one consequence: when the collector is down you get silence, not an error.
|
|
97
98
|
The examples probe for it first and print a hint — worth copying if you hit
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
//#region \0tanstack-start-manifest:v
|
|
2
2
|
var tsrStartManifest = () => ({ routes: {
|
|
3
3
|
__root__: {
|
|
4
|
-
filePath: "/
|
|
4
|
+
filePath: "/home/runner/work/effect-inspect/effect-inspect/app/src/routes/__root.tsx",
|
|
5
5
|
children: ["/"],
|
|
6
6
|
preloads: ["/assets/index-smV05cfr.js", "/assets/rolldown-runtime-CbXtAM7H.js"],
|
|
7
7
|
scripts: [{ attrs: {
|
|
@@ -11,7 +11,7 @@ var tsrStartManifest = () => ({ routes: {
|
|
|
11
11
|
} }]
|
|
12
12
|
},
|
|
13
13
|
"/": {
|
|
14
|
-
filePath: "/
|
|
14
|
+
filePath: "/home/runner/work/effect-inspect/effect-inspect/app/src/routes/index.tsx",
|
|
15
15
|
children: void 0,
|
|
16
16
|
preloads: ["/assets/routes-TKgeFdSW.js"]
|
|
17
17
|
}
|
|
@@ -91,7 +91,7 @@ var HEADERS = { TSS_SHELL: "X-TSS_SHELL" };
|
|
|
91
91
|
* the dev styles URL for route-scoped CSS collection.
|
|
92
92
|
*/
|
|
93
93
|
async function getStartManifest(matchedRoutes) {
|
|
94
|
-
const { tsrStartManifest } = await import("./assets/_tanstack-start-manifest_v-
|
|
94
|
+
const { tsrStartManifest } = await import("./assets/_tanstack-start-manifest_v-CFQ3DEVN.js");
|
|
95
95
|
const startManifest = tsrStartManifest();
|
|
96
96
|
let routes = startManifest.routes;
|
|
97
97
|
routes[rootRouteId];
|
package/dist/client/Client.d.ts
CHANGED
|
@@ -4,12 +4,25 @@
|
|
|
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
|
|
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
|
|
11
|
-
*
|
|
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
27
|
import { Context, Effect, type Scope } from 'effect';
|
|
15
28
|
import type { Socket } from 'effect/unstable/socket';
|
|
@@ -30,7 +43,7 @@ export declare class InspectClient extends InspectClient_base {
|
|
|
30
43
|
export interface Options {
|
|
31
44
|
/** Name shown for this program in the webapp. Defaults to the entry script's file name. */
|
|
32
45
|
readonly programName?: string | undefined;
|
|
33
|
-
/** Outbound queue capacity, in messages. Defaults to
|
|
46
|
+
/** Outbound queue capacity, in messages. Defaults to 131072 (~34 MB). */
|
|
34
47
|
readonly bufferSize?: number | undefined;
|
|
35
48
|
/**
|
|
36
49
|
* How often to sample `process.memoryUsage()`, in milliseconds. Defaults to
|
package/dist/client/Client.js
CHANGED
|
@@ -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
|
|
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
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
19
|
-
|
|
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
|
/**
|
|
@@ -116,24 +170,31 @@ const forkMemorySampler = (deps) => Effect.suspend(() => {
|
|
|
116
170
|
export const make = (options) => Effect.gen(function* () {
|
|
117
171
|
const socket = yield* SocketService.Socket;
|
|
118
172
|
const capacity = options?.bufferSize ?? defaultBufferSize;
|
|
119
|
-
const queue = yield* Queue.
|
|
173
|
+
const queue = yield* Queue.dropping(capacity);
|
|
120
174
|
// Effect's `Crypto` can fail with a PlatformError and nothing on this path
|
|
121
175
|
// is allowed to fail; a session id needs uniqueness, not strength.
|
|
122
176
|
// oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect
|
|
123
177
|
const sessionId = crypto.randomUUID();
|
|
124
178
|
// Tracked here rather than inside the queue so a drop survives a
|
|
125
179
|
// reconnect: it is reported on the next `Hello`, which every reconnect
|
|
126
|
-
// re-sends.
|
|
127
|
-
// the only drop signal there is.
|
|
180
|
+
// re-sends.
|
|
128
181
|
// Open exactly while the queue is empty, so shutdown can ask "is everything
|
|
129
182
|
// on the wire?" rather than guessing with a sleep.
|
|
130
183
|
const flushed = Latch.makeUnsafe(true);
|
|
131
184
|
let dropped = 0;
|
|
132
185
|
let reported = 0;
|
|
186
|
+
// Total and non-blocking by construction, and it has to stay that way: this
|
|
187
|
+
// runs inside `Tracer.span`, `span.end` and a `Logger`, none of which can
|
|
188
|
+
// suspend or fail. A dropping `offerUnsafe` returns `false` when the queue
|
|
189
|
+
// is full instead of suspending, which is the entire reason the queue is
|
|
190
|
+
// dropping rather than a backpressuring `bounded` — refusing a message is a
|
|
191
|
+
// gap in a trace, whereas parking the fiber that emitted it is the traced
|
|
192
|
+
// program running slower because it is being watched.
|
|
133
193
|
const sendUnsafe = (message) => {
|
|
134
|
-
if (Queue.
|
|
194
|
+
if (!Queue.offerUnsafe(queue, message)) {
|
|
135
195
|
dropped += 1;
|
|
136
|
-
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
137
198
|
flushed.closeUnsafe();
|
|
138
199
|
};
|
|
139
200
|
const hello = Effect.clockWith((clock) => Effect.succeed({
|
|
@@ -196,6 +257,41 @@ const connection = (deps) => Effect.gen(function* () {
|
|
|
196
257
|
const reader = yield* deps.socket.reader;
|
|
197
258
|
const writer = yield* deps.socket.writer;
|
|
198
259
|
const write = (message) => Effect.suspend(() => writer.write(clientCodec.encode(message)));
|
|
260
|
+
/**
|
|
261
|
+
* Writes a batch as newline-delimited frames instead of one frame each.
|
|
262
|
+
*
|
|
263
|
+
* The codec already terminates every line with `\n`, so a frame is just
|
|
264
|
+
* the concatenation of its lines and the collector's line splitter carries
|
|
265
|
+
* an unterminated remainder across frames — batching needs no protocol
|
|
266
|
+
* change. Frames are cut at {@link maxFrameBytes} / {@link maxFrameMessages}
|
|
267
|
+
* so one huge batch cannot become one unbounded write.
|
|
268
|
+
*
|
|
269
|
+
* `encodeResult` rather than `encode`: `encode` throws, and a throw here
|
|
270
|
+
* would abandon the rest of an already-dequeued batch and tear down the
|
|
271
|
+
* connection over one out-of-domain field. A message that will not encode
|
|
272
|
+
* is dropped on its own and the batch carries on.
|
|
273
|
+
*/
|
|
274
|
+
const writeBatch = (batch) => Effect.suspend(() => {
|
|
275
|
+
const frames = [];
|
|
276
|
+
let lines = [];
|
|
277
|
+
let bytes = 0;
|
|
278
|
+
for (const message of batch) {
|
|
279
|
+
const encoded = Result.getOrUndefined(clientCodec.encodeResult(message));
|
|
280
|
+
if (encoded === undefined)
|
|
281
|
+
continue;
|
|
282
|
+
if (lines.length > 0 &&
|
|
283
|
+
(bytes + encoded.length > maxFrameBytes || lines.length >= maxFrameMessages)) {
|
|
284
|
+
frames.push(lines.join(''));
|
|
285
|
+
lines = [];
|
|
286
|
+
bytes = 0;
|
|
287
|
+
}
|
|
288
|
+
lines.push(encoded);
|
|
289
|
+
bytes += encoded.length;
|
|
290
|
+
}
|
|
291
|
+
if (lines.length > 0)
|
|
292
|
+
frames.push(lines.join(''));
|
|
293
|
+
return Effect.forEach(frames, (frame) => writer.write(frame), { discard: true });
|
|
294
|
+
});
|
|
199
295
|
yield* Effect.flatMap(deps.hello, write);
|
|
200
296
|
// Keeps the connection warm and surfaces a half-open socket as a write
|
|
201
297
|
// failure, which is what triggers the reconnect.
|
|
@@ -206,7 +302,7 @@ const connection = (deps) => Effect.gen(function* () {
|
|
|
206
302
|
const gap = yield* deps.reportDropped;
|
|
207
303
|
if (gap !== undefined)
|
|
208
304
|
yield* write(gap);
|
|
209
|
-
yield*
|
|
305
|
+
yield* writeBatch(batch);
|
|
210
306
|
// Everything offered so far is on the wire. `sendUnsafe` closes the latch
|
|
211
307
|
// again on the next message, so this tracks the queue rather than latching
|
|
212
308
|
// permanently on the first quiet moment.
|