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: 8192, // outbound queue, in messages
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 oldest messages are
93
- dropped and the gap is reported as a warning in the trace, rather than pushing
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: "/Users/juliaortiz/Documents/dev.nosync/effect-inspect/app/src/routes/__root.tsx",
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: "/Users/juliaortiz/Documents/dev.nosync/effect-inspect/app/src/routes/index.tsx",
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-Co953HeC.js");
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];
@@ -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 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
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 8192. */
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
@@ -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
  /**
@@ -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.sliding(capacity);
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. A sliding `offerUnsafe` always succeeds, so a full queue is
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.sizeUnsafe(queue) >= capacity)
194
+ if (!Queue.offerUnsafe(queue, message)) {
135
195
  dropped += 1;
136
- Queue.offerUnsafe(queue, message);
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* Effect.forEach(batch, write);
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "effect-inspect",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "private": false,
5
5
  "description": "A live inspector for Effect programs",
6
6
  "repository": {