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.
- package/LICENSE +41 -0
- package/README.md +37 -18
- package/assets/culpa-collector.ps1 +137 -0
- package/assets/culpa-compose.yml +144 -0
- package/assets/install-culpa.ps1 +310 -0
- package/assets/launch-culpa.ps1 +350 -0
- package/assets/register.mjs +713 -0
- package/assets/uninstall-culpa.ps1 +227 -0
- package/bin/culpa.js +21 -10
- package/bin/getculpa.js +147 -0
- package/lib/assets.d.mts +2 -0
- package/lib/assets.mjs +36 -0
- package/lib/bootstrap.d.mts +25 -0
- package/lib/bootstrap.mjs +73 -0
- package/lib/docker.d.mts +76 -0
- package/lib/docker.mjs +270 -0
- package/lib/doctor.d.mts +29 -0
- package/lib/doctor.mjs +405 -0
- package/lib/fetch.d.mts +17 -0
- package/lib/fetch.mjs +211 -0
- package/lib/install-summary.d.mts +18 -0
- package/lib/install-summary.mjs +144 -0
- package/lib/paths.d.mts +9 -0
- package/lib/paths.mjs +61 -0
- package/lib/preflight.d.mts +39 -0
- package/lib/preflight.mjs +169 -0
- package/lib/provision.d.mts +54 -0
- package/lib/provision.mjs +318 -0
- package/lib/repair.d.mts +21 -0
- package/lib/repair.mjs +174 -0
- package/lib/start.d.mts +44 -0
- package/lib/start.mjs +479 -0
- package/lib/status.d.mts +23 -0
- package/lib/status.mjs +74 -0
- package/lib/stop.d.mts +15 -0
- package/lib/stop.mjs +46 -0
- package/lib/tty.d.mts +18 -0
- package/lib/tty.mjs +58 -0
- package/lib/uninstall.d.mts +17 -0
- package/lib/uninstall.mjs +99 -0
- package/package.json +14 -25
- package/scripts/install.js +154 -0
- package/scripts/prepack.js +41 -0
- package/index.js +0 -3
|
@@ -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
|
+
}
|