getculpa 0.0.1 → 1.0.2

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.
@@ -0,0 +1,713 @@
1
+ // Culpa Node runtime collector — the thin shipper (CF13-T5, D-082 ruling B).
2
+ // tasks/collector-plan.md §TASK-5: ZERO usage extraction happens in this
3
+ // file. Its entire job is to observe raw request/response evidence for
4
+ // outbound HTTP traffic and hand it to culpa-collectord (a separate Rust
5
+ // process, native/culpa-collector) over loopback, which runs the EXISTING
6
+ // culpa-capture normalizers. Duplicating usage extraction in JavaScript
7
+ // would create a second cost-truth engine — explicitly forbidden by D-082.
8
+ // No npm dependencies: this file must run as a bare preload in any Node app.
9
+ //
10
+ // Loaded via NODE_OPTIONS="--import=<file URL to this file>" (deployment
11
+ // configuration only — never a source edit; see
12
+ // native/culpa-collector/tests/collector_gate.rs's module doc). Runs BEFORE
13
+ // any application code.
14
+ //
15
+ // FAIL-OPEN — the single most important property here (tasks/collector-plan.
16
+ // md §TASK-5): every internal error is swallowed. If the collector is down,
17
+ // unreachable, slow, or this file itself throws, the application's own
18
+ // traffic must be completely unaffected. A spend tool that takes down
19
+ // production is worse than no spend tool.
20
+ //
21
+ // TWO HOOKS, BOTH REQUIRED (tasks/open-loops.md "T5 mechanism VERIFIED by
22
+ // execution", measured on Node v20.19.6): Node's global `fetch` (and the
23
+ // OpenAI SDK, which rides it) goes through undici's GLOBAL DISPATCHER; the
24
+ // Anthropic SDK (0.27.3) goes through `http.request` directly and is
25
+ // INVISIBLE to a dispatcher-only hook. Hooking only one leaves an entire
26
+ // real SDK uninstrumented while a fetch-only fixture stays green — exactly
27
+ // the stub-only false-green D-082 item 3 warned about.
28
+ //
29
+ // NEVER ALTERS APP BEHAVIOR: every hook here is a TEE, never a
30
+ // replacement — the wrapped dispatch/response handlers/streams always
31
+ // forward to the original in full, with the original's return values passed
32
+ // back unchanged. Nothing here ever consumes a stream the app would
33
+ // otherwise read, delays a response, or changes what the app sees (verified
34
+ // end to end this session — tasks/open-loops.md: "the app's own
35
+ // `ar.content[0].text` and `ar.usage` came back intact").
36
+ //
37
+ // AI-TRAFFIC DISCRIMINATION (CF21-T1, D-091): this collector sits beside a
38
+ // customer's WHOLE outbound traffic — Stripe, Firebase, Sentry, anything
39
+ // else — not just their LLM calls. See the "AI-traffic discrimination"
40
+ // section below (`isAiShapedObservation` and friends): finish() ships an
41
+ // observation ONLY when its URL is a known AI-provider shape or its
42
+ // captured body is affirmatively AI-shaped. This is the earliest-boundary
43
+ // half of the discrimination (defense in depth) — the collectord-side
44
+ // filter (native/culpa-collector/src/discriminate.rs) is the authoritative
45
+ // one; the mailbox boundary is the hard rule D-091 states.
46
+
47
+ import http from "node:http";
48
+ import https from "node:https";
49
+
50
+ // ─────────────────────────────────────────────────────────── configuration
51
+
52
+ const COLLECTOR_URL = process.env.CULPA_COLLECTOR_URL;
53
+ const MAX_BODY_BYTES = 262_144; // 256 KiB per side — bounded buffer, never unbounded growth
54
+ const MAX_IN_FLIGHT = 256; // bounded concurrent observation buffers
55
+ const SHIP_TIMEOUT_MS = 2_000;
56
+ // OBSERVATION_MAX_LIFETIME_MS — hard ceiling on how long one observation may
57
+ // hold its inFlight slot. If an application receives a response and never
58
+ // reads it (no `data` listener, no resume(), no pipe() — the exact shape CR-1
59
+ // requires this file to leave alone), Node's Agent fires NO terminal event on
60
+ // either req or res — not 'end', not 'close' — until the body is drained:
61
+ // measured directly, this holds for well past 6 seconds, and for a real
62
+ // (non-Node) upstream with no idle timeout it can hold forever. Without a
63
+ // ceiling, MAX_IN_FLIGHT such requests permanently exhaust inFlight and
64
+ // newObservation() returns null for the rest of the process's lifetime — the
65
+ // same failure mode CR-2 fixed for "aborted mid-flight", but for "never
66
+ // touched at all". 120s is generous enough that a real, slow LLM completion
67
+ // (streamed or not) is never mistaken for a leak, while still recovering a
68
+ // leaked slot within a bounded, human-noticeable time instead of never.
69
+ // The env override exists ONLY for this file's own test suite (see
70
+ // native/culpa-collector/tests/observation_lifetime_timeout.rs), which
71
+ // cannot wait 120s per assertion; production deployments should never set it.
72
+ const OBSERVATION_MAX_LIFETIME_MS = (() => {
73
+ const override = Number(process.env.CULPA_OBSERVATION_MAX_LIFETIME_MS);
74
+ return Number.isFinite(override) && override > 0 ? override : 120_000;
75
+ })();
76
+
77
+ // The ORIGINAL, unpatched http.request — used ONLY for this file's own
78
+ // outbound POST to culpa-collectord, so the shipper never observes (and
79
+ // never self-loops on) its own traffic. Captured before any patching below.
80
+ const originalHttpRequest = http.request.bind(http);
81
+
82
+ let inFlight = 0;
83
+
84
+ // ────────────────────────────────────── AI-traffic discrimination (CF21-T1)
85
+
86
+ // D-091 ruling (.gsd/decisions.ndjson): "unrelated outbound traffic
87
+ // (Stripe/Firebase/Sentry/anything non-AI) must never be collected,
88
+ // persisted, or shipped to the mailbox — only normalized AI usage evidence
89
+ // leaves the capture plane." EARLIEST-BOUNDARY DISCARD: this file decides
90
+ // whether to ship() at all, BEFORE crossing even the loopback socket to
91
+ // culpa-collectord — shrinking what a revenue service's Stripe/Firebase/
92
+ // Sentry/etc. traffic ever sends over IPC. This is DEFENSE IN DEPTH, not
93
+ // the hard rule: native/culpa-collector/src/discriminate.rs on the collectord
94
+ // side is the AUTHORITATIVE filter (the mailbox boundary is where the rule
95
+ // is actually enforced) — a gap in the logic below is not itself a privacy
96
+ // violation, since nothing reaches the mailbox regardless. Kept as a close
97
+ // mirror of discriminate.rs's signals anyway (rather than "ship everything,
98
+ // let the Rust side sort it out") specifically to reduce loopback traffic on
99
+ // a busy revenue service, per this task's design constraint.
100
+ //
101
+ // PROTOCOL-SHAPE, NOT A HOST ALLOWLIST: `urlLooksLikeKnownAiEndpoint` below
102
+ // is a FAST-PATH ACCEPT HINT ONLY (mirrors endpoint.rs's `match_endpoint`) —
103
+ // it can never be the sole reason something is REJECTED. Any URL it doesn't
104
+ // recognize still gets the real body-shape check
105
+ // (`isAiShapedObservation`), never an automatic discard.
106
+
107
+ /**
108
+ * Mirrors native/culpa-collector/src/endpoint.rs's `match_endpoint` (path
109
+ * checked first — host-agnostic, exactly why the stub fixtures on a bare
110
+ * 127.0.0.1 loopback host still match — then a hostname fallback for the
111
+ * real providers' production API hosts). Deliberately duplicated rather
112
+ * than imported: this file ships as a bare `--import` preload with ZERO npm
113
+ * dependencies and no build step, so it cannot depend on the Rust crate.
114
+ * If endpoint.rs's list ever changes, update this to match (both sides stay
115
+ * independently unit-testable, so a drift shows up as a real test failure,
116
+ * never a silent gap).
117
+ */
118
+ function urlLooksLikeKnownAiEndpoint(url) {
119
+ let parsed;
120
+ try {
121
+ parsed = new URL(url);
122
+ } catch {
123
+ return false; // unparseable URL — no fast-path hint either way
124
+ }
125
+ const path = parsed.pathname;
126
+ if (
127
+ path.includes("/v1/chat/completions") ||
128
+ path.includes("/v1/completions") ||
129
+ path.includes("/v1/responses") ||
130
+ path.includes("/v1/messages")
131
+ ) {
132
+ return true;
133
+ }
134
+ if (path.includes("/models/") && (path.includes(":generateContent") || path.includes(":streamGenerateContent"))) {
135
+ return true;
136
+ }
137
+ const host = parsed.hostname.toLowerCase();
138
+ return (
139
+ host.endsWith("api.openai.com") ||
140
+ host.endsWith("api.anthropic.com") ||
141
+ host.endsWith("generativelanguage.googleapis.com")
142
+ );
143
+ }
144
+
145
+ /**
146
+ * REQUEST shape signal (discriminate.rs's `request_is_ai_shaped`, mirrored):
147
+ * a JSON object carrying a non-empty string "model" AND one of "messages"
148
+ * (array), "prompt" (string/array), or "input" (string/array — CF21-T1
149
+ * review finding 2: an object-shaped "input", e.g. a quoting-service form
150
+ * payload with an unrelated "model" field, is NOT sufficient; only a
151
+ * string/array matches the OpenAI Responses API's real shape, the same
152
+ * precedent "prompt" already follows) — the OpenAI-compatible
153
+ * chat/completions/responses convention (also Anthropic's Messages API
154
+ * request shape).
155
+ */
156
+ function requestIsAiShaped(bodyText) {
157
+ if (!bodyText) return false;
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(bodyText);
161
+ } catch {
162
+ return false;
163
+ }
164
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false;
165
+ if (typeof parsed.model !== "string" || parsed.model.length === 0) return false;
166
+ if (Array.isArray(parsed.messages)) return true;
167
+ if (typeof parsed.prompt === "string" || Array.isArray(parsed.prompt)) return true;
168
+ if (typeof parsed.input === "string" || Array.isArray(parsed.input)) return true;
169
+ return false;
170
+ }
171
+
172
+ /** True when `value` (or any element of an array `value`) carries a
173
+ * top-level "usage" key whose value is a JSON object — RESPONSE shape signal
174
+ * (discriminate.rs's `value_has_usage_like_block`, mirrored). */
175
+ function valueHasUsageLikeBlock(value) {
176
+ if (Array.isArray(value)) return value.some(valueHasUsageLikeBlock);
177
+ if (value !== null && typeof value === "object") {
178
+ const usage = value.usage;
179
+ return !!(usage && typeof usage === "object" && !Array.isArray(usage));
180
+ }
181
+ return false;
182
+ }
183
+
184
+ /**
185
+ * RESPONSE shape signal, handling both a plain JSON body and an SSE-framed
186
+ * streaming one ("data: <json>" lines — the same framing
187
+ * culpa-capture/src/record.rs's `served_model_from` parses).
188
+ */
189
+ function responseHasUsageLikeBlock(bodyText) {
190
+ if (!bodyText) return false;
191
+ try {
192
+ return valueHasUsageLikeBlock(JSON.parse(bodyText));
193
+ } catch {
194
+ return bodyText
195
+ .split("\n")
196
+ .filter((line) => line.startsWith("data: "))
197
+ .map((line) => line.slice(6))
198
+ .filter((payload) => payload !== "[DONE]")
199
+ .some((payload) => {
200
+ try {
201
+ return valueHasUsageLikeBlock(JSON.parse(payload));
202
+ } catch {
203
+ return false;
204
+ }
205
+ });
206
+ }
207
+ }
208
+
209
+ /**
210
+ * The body-shape decision for a URL the fast-path hint above didn't
211
+ * recognize (discriminate.rs's `is_ai_shaped`, mirrored): true when EITHER
212
+ * signal fires. Pure, unit-testable in isolation — no I/O, only the strings
213
+ * this file already accumulated in its own bounded buffers.
214
+ */
215
+ function isAiShapedObservation(requestBody, responseBody) {
216
+ return requestIsAiShaped(requestBody) || responseHasUsageLikeBlock(responseBody);
217
+ }
218
+
219
+ function toBuffer(chunk) {
220
+ if (chunk === undefined || chunk === null) return null;
221
+ if (Buffer.isBuffer(chunk)) return chunk;
222
+ if (typeof chunk === "string") return Buffer.from(chunk, "utf8");
223
+ return null; // not a plain string/Buffer (e.g. a stream) — never guess, never consume it
224
+ }
225
+
226
+ // ────────────────────────────────────────────────────────────── shipping
227
+
228
+ /**
229
+ * Fire-and-forget POST to culpa-collectord. Never awaited by any caller,
230
+ * never throws, never delays or blocks the app — every failure mode
231
+ * (collector down, DNS failure, timeout, malformed URL) is swallowed here.
232
+ * This is F5.2's fail-open guarantee, structurally: nothing upstream of this
233
+ * function ever inspects its outcome.
234
+ */
235
+ function ship(observation) {
236
+ if (!COLLECTOR_URL) return;
237
+ let target;
238
+ try {
239
+ target = new URL("/observe", COLLECTOR_URL);
240
+ } catch {
241
+ return; // malformed CULPA_COLLECTOR_URL — fail open, never throw
242
+ }
243
+ let body;
244
+ try {
245
+ body = Buffer.from(JSON.stringify(observation), "utf8");
246
+ } catch {
247
+ return;
248
+ }
249
+ try {
250
+ const req = originalHttpRequest(target, {
251
+ method: "POST",
252
+ headers: { "content-type": "application/json", "content-length": body.length },
253
+ timeout: SHIP_TIMEOUT_MS,
254
+ });
255
+ // No response body is ever read — culpa-collectord's answer is
256
+ // irrelevant to the shipper. 'error'/'timeout' are handled so an
257
+ // unhandled 'error' event can never crash the process (fail-open).
258
+ req.on("error", () => {});
259
+ req.on("timeout", () => req.destroy());
260
+ req.end(body);
261
+ } catch {
262
+ // fail open — a throw here must never propagate into app code
263
+ }
264
+ }
265
+
266
+ // ──────────────────────────────────────────────────── bounded observation
267
+
268
+ /**
269
+ * One in-flight request/response being teed. Bounded: stops accumulating
270
+ * past MAX_BODY_BYTES PER SIDE and marks that side's own truncation flag
271
+ * (CF21-T1 review finding 1 — PER-SIDE, not one combined flag) rather than
272
+ * growing without limit — culpa-collectord then degrades honestly
273
+ * (envelope.rs's per-side truncation handling) rather than parsing a
274
+ * partial body.
275
+ */
276
+ function newObservation(url) {
277
+ if (inFlight >= MAX_IN_FLIGHT) {
278
+ return null; // bounded: too many concurrent buffers — observe nothing rather than grow unbounded
279
+ }
280
+ inFlight++;
281
+ let requestChunks = [];
282
+ let requestBytes = 0;
283
+ let responseChunks = [];
284
+ let responseBytes = 0;
285
+ let requestTruncated = false;
286
+ let responseTruncated = false;
287
+ let status = 0;
288
+ const startedAtMs = Date.now();
289
+ let finished = false;
290
+
291
+ function append(chunk, chunksRef, bytesKey) {
292
+ if (!chunk) return chunksRef;
293
+ const nextBytes = (bytesKey === "request" ? requestBytes : responseBytes) + chunk.length;
294
+ if (bytesKey === "request") requestBytes = nextBytes;
295
+ else responseBytes = nextBytes;
296
+ if (nextBytes > MAX_BODY_BYTES) {
297
+ if (bytesKey === "request") requestTruncated = true;
298
+ else responseTruncated = true;
299
+ return [];
300
+ }
301
+ chunksRef.push(chunk);
302
+ return chunksRef;
303
+ }
304
+
305
+ function settle() {
306
+ if (finished) return false;
307
+ finished = true;
308
+ inFlight--;
309
+ clearTimeout(lifetimeTimer); // no accumulating timers past this observation's own life
310
+ return true;
311
+ }
312
+
313
+ function finish() {
314
+ if (!settle()) return;
315
+ // Blank only the side that actually overflowed — the intact side's
316
+ // bytes are used exactly as captured (still never persisted past this
317
+ // function: the strip-before-mailbox rule lives in envelope.rs, this is
318
+ // only the ephemeral wire payload to culpa-collectord).
319
+ const requestBody = requestTruncated ? "" : Buffer.concat(requestChunks).toString("utf8");
320
+ const responseBody = responseTruncated ? "" : Buffer.concat(responseChunks).toString("utf8");
321
+ // CF21-T1 (D-091) earliest-boundary discard. A known-AI URL always ships
322
+ // (the fast-path hint — matches the OLD behavior for every existing
323
+ // provider/transport-matrix row, unaffected by this change). Otherwise:
324
+ // FINDING 1 fix — only discard here when BOTH sides are gone (no intact
325
+ // side left to prove AI-shapedness either way) or when whatever IS
326
+ // intact still isn't AI-shaped. Before this fix, a single combined flag
327
+ // discarded an intact, AI-shaped request just because its response
328
+ // alone had overflowed — exactly the case `isAiShapedObservation` below
329
+ // must still get a chance to see, mirroring discriminate.rs's own
330
+ // per-side fix (envelope.rs's `build_envelope_for_unmatched_url`). An
331
+ // intact-but-not-AI-shaped body (Stripe/Firebase/Sentry/anything else)
332
+ // still never crosses the loopback socket at all. This is DEFENSE IN
333
+ // DEPTH only — native/culpa-collector/src/discriminate.rs is the
334
+ // authoritative filter regardless of what this file decides.
335
+ if (!urlLooksLikeKnownAiEndpoint(url)) {
336
+ if (requestTruncated && responseTruncated) {
337
+ return;
338
+ }
339
+ if (!isAiShapedObservation(requestBody, responseBody)) {
340
+ return;
341
+ }
342
+ }
343
+ ship({
344
+ url,
345
+ status,
346
+ occurredAtMs: startedAtMs,
347
+ latencyMs: Date.now() - startedAtMs,
348
+ requestBody,
349
+ responseBody,
350
+ requestTruncated,
351
+ responseTruncated,
352
+ // Derived OR, kept for any OTHER consumer of this wire shape still
353
+ // reading the old combined flag — culpa-collectord itself reads the
354
+ // two granular fields above (server.rs's parse_observation).
355
+ bodyTruncated: requestTruncated || responseTruncated,
356
+ });
357
+ }
358
+
359
+ function abort() {
360
+ // no ship() on a failed/aborted call — nothing complete to report; an
361
+ // app-level retry (if any) produces its own, separate observation
362
+ settle();
363
+ }
364
+
365
+ // Expiry policy: SHIP what was actually observed, forced through the
366
+ // SAME truncation-degrade path the oversized-body case already uses
367
+ // (both sides marked truncated) rather than a new, separate code path.
368
+ // This keeps real, already-observed status/timing/latency data while
369
+ // blanking BOTH bodies, so nothing downstream ever sees a response body
370
+ // that was never actually captured — honest degradation, never a
371
+ // fabrication. Unlike a genuine single-side overflow (where the OTHER
372
+ // side is still trustworthy), an expiry means the call's own completion
373
+ // state is unknown, so neither side is trusted here, deliberately. A
374
+ // silent drop (mirroring abort()) was considered and rejected: it would
375
+ // erase a real, timed, real-status call from spend forensics entirely,
376
+ // which is worse for a cost tool than a metadata-only row.
377
+ //
378
+ // unref()'d so this timer can NEVER keep a short-lived script or CLI
379
+ // process alive on its own — a preload that prevents process exit is
380
+ // itself the class of app-behavior change this whole file exists to
381
+ // avoid (see tests/observation_lifetime_timeout.rs).
382
+ const lifetimeTimer = setTimeout(() => {
383
+ try {
384
+ requestTruncated = true;
385
+ responseTruncated = true;
386
+ finish();
387
+ } catch {
388
+ /* fail open */
389
+ }
390
+ }, OBSERVATION_MAX_LIFETIME_MS);
391
+ lifetimeTimer.unref();
392
+
393
+ return {
394
+ setStatus(s) {
395
+ status = s;
396
+ },
397
+ appendRequest(chunk) {
398
+ requestChunks = append(chunk, requestChunks, "request");
399
+ },
400
+ appendResponse(chunk) {
401
+ responseChunks = append(chunk, responseChunks, "response");
402
+ },
403
+ finish,
404
+ abort,
405
+ };
406
+ }
407
+
408
+ // ─────────────────────────────────────────────────────── undici dispatcher
409
+
410
+ // Measured this session (tasks/open-loops.md): the dispatcher Agent at this
411
+ // symbol is created LAZILY, synchronously, as part of undici's OWN handling
412
+ // of the first `fetch()` call in the process — a pre-installed
413
+ // getter/setter trap on this symbol does NOT intercept that creation
414
+ // (undici replaces the whole property descriptor via `Object.defineProperty`
415
+ // rather than a plain assignment, confirmed by direct probe: a getter/setter
416
+ // pair installed here beforehand simply never fires). The verified technique
417
+ // is instead: force the dispatcher into existence with a harmless, ignored
418
+ // WARM-UP fetch() call at install time (this file runs before any app code,
419
+ // so this happens before the app's own first fetch), then mutate the
420
+ // resulting Agent's `.dispatch` method IN PLACE. Because the Agent instance
421
+ // is reused for the process lifetime, every subsequent fetch — including
422
+ // the application's very first — dispatches through the wrapped method.
423
+ const DISPATCHER_SYMBOL = Symbol.for("undici.globalDispatcher.1");
424
+
425
+ function wrapDispatcher(agent) {
426
+ try {
427
+ if (!agent || typeof agent.dispatch !== "function" || agent.__culpaWrapped) {
428
+ return agent;
429
+ }
430
+ const originalDispatch = agent.dispatch.bind(agent);
431
+ agent.dispatch = function (opts, handler) {
432
+ try {
433
+ const observation = beginDispatchObservation(opts);
434
+ if (!observation) return originalDispatch(opts, handler);
435
+ return originalDispatch(opts, tapHandler(handler, observation));
436
+ } catch {
437
+ // fail open: any error while SETTING UP the observation falls back
438
+ // to the exact unwrapped call — zero behavior change either way
439
+ return originalDispatch(opts, handler);
440
+ }
441
+ };
442
+ agent.__culpaWrapped = true;
443
+ return agent;
444
+ } catch {
445
+ return agent; // fail open — return the agent unwrapped rather than throw
446
+ }
447
+ }
448
+
449
+ function beginDispatchObservation(opts) {
450
+ if (!opts) return null;
451
+ const origin = typeof opts.origin === "string" ? opts.origin : String(opts.origin ?? "");
452
+ const path = typeof opts.path === "string" ? opts.path : "";
453
+ const observation = newObservation(origin + path);
454
+ if (!observation) return null;
455
+ // Best-effort only: a fetch() body reaches undici's dispatch options as a
456
+ // stream in the general case, not the plain string the caller passed to
457
+ // fetch() — teeing a live request stream here would risk altering app
458
+ // behavior (exactly what is forbidden), so only the plain string/Buffer
459
+ // case is captured; anything else is silently skipped (usage tokens come
460
+ // from the RESPONSE body, which is always captured below regardless).
461
+ const bodyBuf = toBuffer(opts.body);
462
+ if (bodyBuf) observation.appendRequest(bodyBuf);
463
+ return observation;
464
+ }
465
+
466
+ /**
467
+ * A Proxy that forwards every call to the ORIGINAL handler unchanged
468
+ * (including its return value — undici's onData return value signals
469
+ * backpressure and must never be altered), while also feeding onHeaders/
470
+ * onData/onComplete/onError to the bounded observation buffer. This IS the
471
+ * tee: the app's own response is byte-identical either way (verified this
472
+ * session: `await r.json()` returned the body byte-intact through this
473
+ * exact wrap).
474
+ */
475
+ function tapHandler(handler, observation) {
476
+ return new Proxy(handler, {
477
+ get(target, prop, receiver) {
478
+ const original = Reflect.get(target, prop, receiver);
479
+ if (prop === "onHeaders" && typeof original === "function") {
480
+ return function (statusCode, headers, resume, statusText) {
481
+ try {
482
+ observation.setStatus(statusCode);
483
+ } catch {
484
+ /* fail open */
485
+ }
486
+ return original.call(target, statusCode, headers, resume, statusText);
487
+ };
488
+ }
489
+ if (prop === "onData" && typeof original === "function") {
490
+ return function (chunk) {
491
+ try {
492
+ observation.appendResponse(toBuffer(chunk));
493
+ } catch {
494
+ /* fail open */
495
+ }
496
+ return original.call(target, chunk);
497
+ };
498
+ }
499
+ if (prop === "onComplete" && typeof original === "function") {
500
+ return function (trailers) {
501
+ try {
502
+ observation.finish();
503
+ } catch {
504
+ /* fail open */
505
+ }
506
+ return original.call(target, trailers);
507
+ };
508
+ }
509
+ if (prop === "onError" && typeof original === "function") {
510
+ return function (err) {
511
+ try {
512
+ observation.abort();
513
+ } catch {
514
+ /* fail open */
515
+ }
516
+ return original.call(target, err);
517
+ };
518
+ }
519
+ if (typeof original === "function") {
520
+ return original.bind(target);
521
+ }
522
+ return original;
523
+ },
524
+ });
525
+ }
526
+
527
+ function installDispatcherHook() {
528
+ const originalFetch = globalThis.fetch;
529
+ if (typeof originalFetch !== "function") return; // no global fetch in this runtime — nothing to hook
530
+ try {
531
+ // The warm-up: target refused instantly on loopback (nothing ever binds
532
+ // port 1), ignored entirely — its only purpose is forcing undici to
533
+ // create+store the global dispatcher SYNCHRONOUSLY, before returning, so
534
+ // it can be wrapped here rather than missed on the app's first real call.
535
+ originalFetch("http://127.0.0.1:1/culpa-collector-warmup").catch(() => {});
536
+ } catch {
537
+ /* fail open — dispatcher hook simply won't engage this run */
538
+ }
539
+ try {
540
+ wrapDispatcher(globalThis[DISPATCHER_SYMBOL]);
541
+ } catch {
542
+ /* fail open */
543
+ }
544
+ }
545
+
546
+ // ──────────────────────────────────────────────────────────────── http/https
547
+
548
+ /**
549
+ * CR-1 (PR #15 review): patches res's own `emit` so this tee observes
550
+ * data/end/aborted/error exactly when the APP's own consumption already
551
+ * causes those events to fire — NEVER by attaching a `data` listener here,
552
+ * which would itself switch the stream into flowing mode and start it
553
+ * (the previous version of this file did exactly that, and it silently
554
+ * drained the app's own response body — see tapHttpRequest's call site for
555
+ * the full rationale and tests/response_body_integrity.rs for the proof).
556
+ * If the app never reads the response, res.emit('data', …) never fires at
557
+ * all here, nothing is captured, and the stream stays paused: honest
558
+ * degradation, never a silent theft of the app's body.
559
+ */
560
+ function tapResponseEmit(res, observation) {
561
+ const originalEmit = res.emit.bind(res);
562
+ res.emit = function (event, ...args) {
563
+ if (event === "data") {
564
+ try {
565
+ observation.appendResponse(toBuffer(args[0]));
566
+ } catch {
567
+ /* fail open */
568
+ }
569
+ } else if (event === "end") {
570
+ try {
571
+ observation.finish();
572
+ } catch {
573
+ /* fail open */
574
+ }
575
+ } else if (event === "aborted" || event === "error") {
576
+ try {
577
+ observation.abort();
578
+ } catch {
579
+ /* fail open */
580
+ }
581
+ }
582
+ return originalEmit(event, ...args);
583
+ };
584
+ }
585
+
586
+ /**
587
+ * tasks/open-loops.md: the Anthropic SDK (0.27.3) rides http.request
588
+ * directly, NOT global fetch — invisible to the dispatcher hook above.
589
+ * Wraps `.request` on both modules (verified this session: status + full
590
+ * response body observed via a 'response'/'data'/'end' tee while the
591
+ * caller's own read of the same response stayed intact).
592
+ */
593
+ function tapHttpRequest(req) {
594
+ const observation = newObservation(`${req.protocol || "http:"}//${req.host || ""}${req.path || "/"}`);
595
+ if (!observation) return;
596
+
597
+ // Tee the OUTGOING request body: wrap write()/end() so every byte the app
598
+ // sends upstream is also captured, without changing what reaches the
599
+ // socket — the original write/end still run with the exact same
600
+ // arguments and the exact same return value.
601
+ const originalWrite = req.write.bind(req);
602
+ req.write = function (chunk, ...rest) {
603
+ try {
604
+ observation.appendRequest(toBuffer(chunk));
605
+ } catch {
606
+ /* fail open */
607
+ }
608
+ return originalWrite(chunk, ...rest);
609
+ };
610
+ const originalEnd = req.end.bind(req);
611
+ req.end = function (chunk, ...rest) {
612
+ if (chunk !== undefined && typeof chunk !== "function") {
613
+ try {
614
+ observation.appendRequest(toBuffer(chunk));
615
+ } catch {
616
+ /* fail open */
617
+ }
618
+ }
619
+ return originalEnd(chunk, ...rest);
620
+ };
621
+
622
+ req.on("response", (res) => {
623
+ try {
624
+ observation.setStatus(res.statusCode);
625
+ } catch {
626
+ /* fail open */
627
+ }
628
+ // CR-1 (PR #15 review): NEVER attach a `data` listener here — the
629
+ // previous version of this comment ("Passive tee only ... just observes
630
+ // bytes as they flow") was WRONG, and dangerously so: attaching `data`
631
+ // is what STARTS flowing mode, it does not merely observe it. See
632
+ // tapResponseEmit's own doc comment for the fix and
633
+ // tests/response_body_integrity.rs for the reproduction and proof.
634
+ tapResponseEmit(res, observation);
635
+ });
636
+ req.on("error", () => {
637
+ try {
638
+ observation.abort();
639
+ } catch {
640
+ /* fail open */
641
+ }
642
+ });
643
+ // CR-2 (PR #15 review): Node's http.ClientRequest fires 'close' as its one
644
+ // DOCUMENTED, always-fires terminal event, covering every way a request
645
+ // can end. Corrected claim (2026-08 review pass — the original wording
646
+ // here overstated what has actually been observed): on Node v20.19.6,
647
+ // direct probing across nine distinct abort paths (client-side
648
+ // destroy()/socket destroy()/AbortController abort, server-side resets
649
+ // before and after headers, res.destroy() from the client) found that
650
+ // 'error' on req (pre-response) or 'aborted'/'error' on res (post-
651
+ // response) already fires for every one of them, ahead of 'close' — see
652
+ // native/culpa-collector/tests/inflight_counter_leak.rs's module doc for
653
+ // the full probe list and results. So on this Node build, none of the
654
+ // probed paths currently rely on 'close' alone to free the slot.
655
+ // This handler stays in as defense-in-depth: unlike 'close', 'error' is
656
+ // NOT documented as firing on every abort path, on every Node version, on
657
+ // every platform — a future Node release or an abort path not yet probed
658
+ // could settle a request without 'error'/'aborted' firing at all, and
659
+ // only 'close' would catch it. abort() is idempotent (settle()'s
660
+ // `finished` guard), so keeping this handler costs nothing: it never
661
+ // double-decrements and never double-ships even when finish()/abort()
662
+ // already ran via the normal path above.
663
+ req.on("close", () => {
664
+ try {
665
+ observation.abort();
666
+ } catch {
667
+ /* fail open */
668
+ }
669
+ });
670
+ }
671
+
672
+ function patchHttpModule(mod) {
673
+ const original = mod.request;
674
+ mod.request = function (...args) {
675
+ const req = original.apply(mod, args);
676
+ try {
677
+ tapHttpRequest(req);
678
+ } catch {
679
+ /* fail open — the real request object is returned untouched either way */
680
+ }
681
+ return req;
682
+ };
683
+ }
684
+
685
+ function installHttpHooks() {
686
+ try {
687
+ patchHttpModule(http);
688
+ } catch {
689
+ /* fail open */
690
+ }
691
+ try {
692
+ patchHttpModule(https);
693
+ } catch {
694
+ /* fail open */
695
+ }
696
+ }
697
+
698
+ // ──────────────────────────────────────────────────────────────── install
699
+
700
+ // Both hooks, unconditionally, each independently wrapped so a throw during
701
+ // install of ONE can never prevent the other from installing, and can never
702
+ // reach app startup either way (fail-open covers installation itself, not
703
+ // just steady-state operation).
704
+ try {
705
+ installDispatcherHook();
706
+ } catch {
707
+ /* fail open */
708
+ }
709
+ try {
710
+ installHttpHooks();
711
+ } catch {
712
+ /* fail open */
713
+ }