@bounded-sh/observe 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ export declare function patchFetch(): void;
2
+ export declare function unpatchFetch(): void;
@@ -0,0 +1,534 @@
1
+ "use strict";
2
+ // ---------------------------------------------------------------------------
3
+ // intercept/fetch.ts -- globalThis.fetch (undici) interception (SPEC §3.1a).
4
+ //
5
+ // INVARIANTS (T-shim, binding):
6
+ // * The app's call ALWAYS goes through: any shim failure falls back to the
7
+ // original fetch with the original arguments; observation errors are
8
+ // swallowed and counted, never thrown.
9
+ // * Zero synchronous work beyond cheap metadata on the hot path; body
10
+ // parsing/classification happens on the pipeline tick.
11
+ // * X-Bounded-* headers NEVER egress: they are stripped here (and used as
12
+ // an attribution fallback when no AsyncLocalStorage context is present).
13
+ // * Self-traffic (the ingest host) is never captured (no feedback loop).
14
+ // ---------------------------------------------------------------------------
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.patchFetch = patchFetch;
17
+ exports.unpatchFetch = unpatchFetch;
18
+ const node_crypto_1 = require("node:crypto");
19
+ const context_1 = require("../context");
20
+ const escort_1 = require("../escort");
21
+ const llm_prices_1 = require("../llm-prices");
22
+ const state_1 = require("../state");
23
+ const template_1 = require("../template");
24
+ const BOUNDED_HEADER_RE = /^x-bounded-/i;
25
+ const BODY_CAP = 32 * 1024;
26
+ const RESP_JSON_CAP = 512 * 1024;
27
+ /** Per (host|method) request-body samples retained for shape field names. */
28
+ const SHAPE_BODY_SAMPLES = 5;
29
+ let realFetch;
30
+ const shapeBodyBudget = new Map();
31
+ function aliasHost(host) {
32
+ return state_1.state.config?.aliasHosts?.[host] ?? host;
33
+ }
34
+ function noteBoundedHeader(acc, name, value) {
35
+ const n = name.toLowerCase();
36
+ if (n === "x-bounded-actor")
37
+ acc.id = value;
38
+ else if (n === "x-bounded-actor-kind")
39
+ acc.kind = value;
40
+ else if (n === "x-bounded-on-behalf-of")
41
+ acc.onBehalfOf = value;
42
+ }
43
+ function headerActorToContext(acc) {
44
+ if (!acc.id)
45
+ return undefined;
46
+ const kind = acc.kind === "human" || acc.kind === "agent" || acc.kind === "service" ? acc.kind : "unknown";
47
+ const ctx = { id: acc.id.slice(0, 256), kind };
48
+ if (acc.onBehalfOf)
49
+ ctx.onBehalfOf = acc.onBehalfOf.slice(0, 256);
50
+ return ctx;
51
+ }
52
+ /** Strip X-Bounded-* from a Headers instance IN PLACE; collect their values. */
53
+ function stripHeadersInstance(h, acc) {
54
+ const doomed = [];
55
+ h.forEach((value, name) => {
56
+ if (BOUNDED_HEADER_RE.test(name)) {
57
+ noteBoundedHeader(acc, name, value);
58
+ doomed.push(name);
59
+ }
60
+ });
61
+ for (const name of doomed)
62
+ h.delete(name);
63
+ }
64
+ /** Return a cleaned copy of a HeadersInit, or undefined if nothing to strip. */
65
+ function cleanHeadersInit(headers, acc) {
66
+ if (!headers)
67
+ return undefined;
68
+ if (headers instanceof Headers) {
69
+ let found = false;
70
+ headers.forEach((_v, name) => {
71
+ if (BOUNDED_HEADER_RE.test(name))
72
+ found = true;
73
+ });
74
+ if (!found)
75
+ return undefined;
76
+ const copy = new Headers(headers);
77
+ stripHeadersInstance(copy, acc);
78
+ return copy;
79
+ }
80
+ if (Array.isArray(headers)) {
81
+ let found = false;
82
+ for (const pair of headers) {
83
+ if (Array.isArray(pair) && typeof pair[0] === "string" && BOUNDED_HEADER_RE.test(pair[0])) {
84
+ found = true;
85
+ noteBoundedHeader(acc, pair[0], String(pair[1]));
86
+ }
87
+ }
88
+ if (!found)
89
+ return undefined;
90
+ return headers.filter((p) => !(Array.isArray(p) && BOUNDED_HEADER_RE.test(String(p[0]))));
91
+ }
92
+ if (typeof headers === "object") {
93
+ const obj = headers;
94
+ let found = false;
95
+ for (const k of Object.keys(obj)) {
96
+ if (BOUNDED_HEADER_RE.test(k)) {
97
+ found = true;
98
+ noteBoundedHeader(acc, k, String(obj[k]));
99
+ }
100
+ }
101
+ if (!found)
102
+ return undefined;
103
+ const copy = {};
104
+ for (const k of Object.keys(obj)) {
105
+ if (!BOUNDED_HEADER_RE.test(k))
106
+ copy[k] = obj[k];
107
+ }
108
+ return copy;
109
+ }
110
+ return undefined;
111
+ }
112
+ function headerFromInit(headers, name) {
113
+ try {
114
+ if (!headers)
115
+ return undefined;
116
+ if (headers instanceof Headers)
117
+ return headers.get(name) ?? undefined;
118
+ if (Array.isArray(headers)) {
119
+ for (const p of headers) {
120
+ if (Array.isArray(p) && String(p[0]).toLowerCase() === name)
121
+ return String(p[1]);
122
+ }
123
+ return undefined;
124
+ }
125
+ if (typeof headers === "object") {
126
+ for (const k of Object.keys(headers)) {
127
+ if (k.toLowerCase() === name)
128
+ return String(headers[k]);
129
+ }
130
+ }
131
+ }
132
+ catch {
133
+ /* fall through */
134
+ }
135
+ return undefined;
136
+ }
137
+ function bodyToText(body) {
138
+ if (typeof body === "string") {
139
+ return { text: body.slice(0, BODY_CAP), bytes: Buffer.byteLength(body) };
140
+ }
141
+ if (body instanceof URLSearchParams) {
142
+ const s = body.toString();
143
+ return { text: s.slice(0, BODY_CAP), bytes: Buffer.byteLength(s) };
144
+ }
145
+ if (body instanceof Uint8Array) {
146
+ return {
147
+ text: Buffer.from(body.buffer, body.byteOffset, Math.min(body.byteLength, BODY_CAP)).toString("utf8"),
148
+ bytes: body.byteLength,
149
+ };
150
+ }
151
+ if (body instanceof ArrayBuffer) {
152
+ return {
153
+ text: Buffer.from(body, 0, Math.min(body.byteLength, BODY_CAP)).toString("utf8"),
154
+ bytes: body.byteLength,
155
+ };
156
+ }
157
+ return { bytes: 0 }; // streams / FormData / Blob: metadata only
158
+ }
159
+ function shapeBodyAllowance(host, method) {
160
+ const key = `${host}|${method}`;
161
+ const used = shapeBodyBudget.get(key) ?? 0;
162
+ if (used >= SHAPE_BODY_SAMPLES)
163
+ return false;
164
+ if (shapeBodyBudget.size > 2_048 && !shapeBodyBudget.has(key))
165
+ return false;
166
+ shapeBodyBudget.set(key, used + 1);
167
+ return true;
168
+ }
169
+ function preprocess(input, init) {
170
+ // Resolve URL (absolute in Node fetch).
171
+ let url;
172
+ if (typeof input === "string")
173
+ url = new URL(input);
174
+ else if (input instanceof URL)
175
+ url = input;
176
+ else if (input && typeof input.url === "string")
177
+ url = new URL(input.url);
178
+ else
179
+ return undefined;
180
+ const rawHost = url.host;
181
+ const host = aliasHost(rawHost);
182
+ if (rawHost === state_1.state.ingestHost || host === state_1.state.ingestHost)
183
+ return undefined; // self
184
+ const isRequest = typeof input === "object" && input !== null && typeof input.url === "string";
185
+ const method = (init?.method ?? (isRequest ? input.method : undefined) ?? "GET").toUpperCase();
186
+ const acc = {};
187
+ let cleanedInit;
188
+ // Strip X-Bounded-* wherever they might live.
189
+ if (isRequest) {
190
+ try {
191
+ stripHeadersInstance(input.headers, acc);
192
+ }
193
+ catch {
194
+ /* immutable headers: values still never egress via init below */
195
+ }
196
+ }
197
+ if (init?.headers) {
198
+ const cleaned = cleanHeadersInit(init.headers, acc);
199
+ if (cleaned !== undefined)
200
+ cleanedInit = { ...init, headers: cleaned };
201
+ }
202
+ // Request metadata.
203
+ const effHeaders = init?.headers ?? (isRequest ? input.headers : undefined);
204
+ const contentType = headerFromInit(effHeaders, "content-type");
205
+ const contentLength = Number(headerFromInit(effHeaders, "content-length"));
206
+ const meta = {
207
+ host,
208
+ path: url.pathname,
209
+ method,
210
+ actor: (0, context_1.currentActor)() ?? headerActorToContext(acc),
211
+ bytesIn: Number.isFinite(contentLength) && contentLength > 0 ? contentLength : 0,
212
+ reqContentType: contentType,
213
+ wantsRespBody: false,
214
+ cleanedInit,
215
+ ts: Date.now(),
216
+ };
217
+ if (url.search && url.search.length > 1) {
218
+ meta.queryNames = [...url.searchParams.keys()].slice(0, 64);
219
+ }
220
+ // Body capture: manifest (recognizer) hosts always; otherwise a small
221
+ // per-route allowance so shape samples get field names.
222
+ const recognizedHost = state_1.state.manifest.captureHosts.has(host);
223
+ if (method !== "GET" && method !== "HEAD") {
224
+ const wantBody = recognizedHost || shapeBodyAllowance(host, method);
225
+ const body = init?.body;
226
+ if (wantBody && body !== undefined && body !== null) {
227
+ const { text, bytes } = bodyToText(body);
228
+ if (text !== undefined)
229
+ meta.reqBodyText = text;
230
+ if (meta.bytesIn === 0 && bytes > 0)
231
+ meta.bytesIn = bytes;
232
+ }
233
+ else if (wantBody && recognizedHost && isRequest && input.body) {
234
+ // Request-with-stream body: clone-read (async, off the hot path).
235
+ try {
236
+ const req = input.clone();
237
+ meta.reqBodyPromise = req
238
+ .text()
239
+ .then((t) => t.slice(0, BODY_CAP))
240
+ .catch(() => undefined);
241
+ if (!meta.reqContentType)
242
+ meta.reqContentType = input.headers.get("content-type") ?? undefined;
243
+ }
244
+ catch {
245
+ /* metadata only */
246
+ }
247
+ }
248
+ }
249
+ // Does the manifest want response fields for this route?
250
+ if (recognizedHost) {
251
+ meta.wantsRespBody = state_1.state.manifest.wantsResponseBody(host, method, (0, template_1.templatePath)(url.pathname));
252
+ }
253
+ return meta;
254
+ }
255
+ function finalize(meta, startMs, res) {
256
+ const durMs = performance.now() - startMs;
257
+ const status = res?.status ?? 0;
258
+ let bytesOut = 0;
259
+ try {
260
+ const cl = Number(res?.headers.get("content-length"));
261
+ if (Number.isFinite(cl) && cl > 0)
262
+ bytesOut = cl;
263
+ }
264
+ catch {
265
+ /* ignore */
266
+ }
267
+ const raw = {
268
+ ts: meta.ts,
269
+ host: meta.host,
270
+ path: meta.path,
271
+ queryNames: meta.queryNames,
272
+ method: meta.method,
273
+ status,
274
+ durMs,
275
+ bytesIn: meta.bytesIn,
276
+ bytesOut,
277
+ actor: meta.actor,
278
+ reqBodyText: meta.reqBodyText,
279
+ reqContentType: meta.reqContentType,
280
+ };
281
+ // Response JSON capture (recognized routes with responseFields only).
282
+ let respPromise;
283
+ if (res && meta.wantsRespBody) {
284
+ try {
285
+ const ct = res.headers.get("content-type") ?? "";
286
+ const cl = Number(res.headers.get("content-length"));
287
+ const sizeOk = !Number.isFinite(cl) || cl <= RESP_JSON_CAP;
288
+ if (ct.toLowerCase().includes("json") && sizeOk) {
289
+ const clone = res.clone();
290
+ respPromise = Promise.race([
291
+ clone.json().then((j) => {
292
+ raw.respJson = j;
293
+ }),
294
+ new Promise((resolve) => {
295
+ const t = setTimeout(resolve, 10_000);
296
+ t.unref?.();
297
+ }),
298
+ ]).catch(() => undefined);
299
+ }
300
+ }
301
+ catch {
302
+ /* metadata only */
303
+ }
304
+ }
305
+ const enqueue = () => state_1.state.pipeline?.enqueueRaw(raw);
306
+ if (meta.reqBodyPromise || respPromise) {
307
+ const waits = [];
308
+ if (meta.reqBodyPromise) {
309
+ waits.push(meta.reqBodyPromise.then((t) => {
310
+ if (t !== undefined && raw.reqBodyText === undefined)
311
+ raw.reqBodyText = t;
312
+ }));
313
+ }
314
+ if (respPromise)
315
+ waits.push(respPromise);
316
+ void Promise.allSettled(waits).then(enqueue);
317
+ }
318
+ else {
319
+ enqueue();
320
+ }
321
+ }
322
+ // --- Escort (Mode A, T9) -----------------------------------------------------
323
+ /** Escort config for this call, or undefined. Zero work unless escort mode is
324
+ * on (hasEscorts); reads (GET/HEAD) are never escorted (U16). */
325
+ function escortFor(meta) {
326
+ if (!state_1.state.manifest.hasEscorts)
327
+ return undefined;
328
+ if (meta.method === "GET" || meta.method === "HEAD")
329
+ return undefined;
330
+ return state_1.state.manifest.escort(meta.host, meta.method, (0, template_1.templatePath)(meta.path));
331
+ }
332
+ /** Merge headers (Request's own first, then init's) and add Idempotency-Key.
333
+ * Never mutates the app's original request/init — returns a fresh init. */
334
+ function withIdempotencyKey(input, init, intentId) {
335
+ const h = new Headers();
336
+ if (input && typeof input === "object" && typeof input.url === "string") {
337
+ try {
338
+ input.headers.forEach((v, k) => h.set(k, v));
339
+ }
340
+ catch {
341
+ /* immutable headers: init headers below still carry through */
342
+ }
343
+ }
344
+ if (init?.headers) {
345
+ try {
346
+ new Headers(init.headers).forEach((v, k) => h.set(k, v));
347
+ }
348
+ catch {
349
+ /* ignore malformed headers */
350
+ }
351
+ }
352
+ h.set("Idempotency-Key", intentId);
353
+ return { ...(init ?? {}), headers: h };
354
+ }
355
+ /** Settle from the fired response (reads a CLONE off the hot path for the
356
+ * rail result id; never blocks the response returned to the app). */
357
+ function reportSettleFromResponse(cfg, intentId, res, failOpen) {
358
+ const base = {
359
+ outcome: failOpen ? "fail_open" : "fired",
360
+ status: res.status,
361
+ };
362
+ if (failOpen)
363
+ base.failOpen = true;
364
+ try {
365
+ const ct = (res.headers.get("content-type") ?? "").toLowerCase();
366
+ if (ct.includes("json")) {
367
+ void res
368
+ .clone()
369
+ .json()
370
+ .then((j) => {
371
+ const id = j && typeof j.id === "string" ? j.id : undefined;
372
+ // llm-gateway rail (§4.3, Track H): price the ACTUAL usage-cost from the
373
+ // fired call's response so the enforcement worker can DECREASE the
374
+ // estCents reservation (release headroom). Metadata only — token counts
375
+ // + model echo, never message content. Any other rail: id-only settle.
376
+ let actualCents;
377
+ if (cfg.rail === "llm-gateway") {
378
+ const usage = (0, llm_prices_1.usageFromResponse)(j);
379
+ if (usage)
380
+ actualCents = (0, llm_prices_1.actualCentsFromUsage)(usage).actualCents;
381
+ }
382
+ (0, escort_1.settleAsync)(cfg, intentId, { ...base, resultId: id, ...(actualCents !== undefined ? { actualCents } : {}) });
383
+ })
384
+ .catch(() => (0, escort_1.settleAsync)(cfg, intentId, base));
385
+ return;
386
+ }
387
+ }
388
+ catch {
389
+ /* fall through to id-less settle */
390
+ }
391
+ (0, escort_1.settleAsync)(cfg, intentId, base);
392
+ }
393
+ /** Fire the (verdict-approved) call from the app's own process with the
394
+ * Idempotency-Key, and emit the normal observe event for it. */
395
+ async function fireEscorted(orig, input, effInit, meta, intentId) {
396
+ const idemInit = withIdempotencyKey(input, effInit, intentId);
397
+ const start = performance.now();
398
+ let res;
399
+ try {
400
+ res = await orig(input, idemInit);
401
+ }
402
+ catch (err) {
403
+ try {
404
+ finalize(meta, start, undefined); // observe the failed egress (status 0)
405
+ }
406
+ catch (e) {
407
+ (0, state_1.noteInternalError)("escort.finalize", e);
408
+ }
409
+ throw err;
410
+ }
411
+ try {
412
+ finalize(meta, start, res);
413
+ }
414
+ catch (e) {
415
+ (0, state_1.noteInternalError)("escort.finalize", e);
416
+ }
417
+ return res;
418
+ }
419
+ /** Orchestrate the escort round-trip for one matched call. Throws a rail-shaped
420
+ * local error on any non-fire verdict; fires from the app process otherwise. */
421
+ async function runEscort(orig, input, effInit, meta, cfg) {
422
+ const intentId = (0, node_crypto_1.randomUUID)();
423
+ // Intent fields come from the request the app ALREADY built (no mutation).
424
+ let bodyText = meta.reqBodyText;
425
+ if (bodyText === undefined && meta.reqBodyPromise) {
426
+ bodyText = await meta.reqBodyPromise.catch(() => undefined);
427
+ }
428
+ const intent = (0, escort_1.buildIntentFields)(bodyText, meta.reqContentType, cfg);
429
+ let verdict;
430
+ try {
431
+ verdict = await (0, escort_1.requestVerdict)(cfg, intentId, intent, meta.actor);
432
+ }
433
+ catch {
434
+ // Verdict endpoint unreachable/timeout: apply failMode LOCALLY (U18).
435
+ if (cfg.failMode === "open") {
436
+ const res = await fireEscorted(orig, input, effInit, meta, intentId);
437
+ reportSettleFromResponse(cfg, intentId, res, true);
438
+ return res;
439
+ }
440
+ throw (0, escort_1.buildRailError)(cfg, { kind: "fail_closed", intentId });
441
+ }
442
+ if (verdict.verdict === "allowed") {
443
+ const res = await fireEscorted(orig, input, effInit, meta, intentId);
444
+ reportSettleFromResponse(cfg, intentId, res, false);
445
+ return res;
446
+ }
447
+ if (verdict.verdict === "pending_approval") {
448
+ throw (0, escort_1.buildRailError)(cfg, { kind: "pending_approval", intentId, approveUrl: verdict.approveUrl });
449
+ }
450
+ throw (0, escort_1.buildRailError)(cfg, {
451
+ kind: "declined",
452
+ intentId,
453
+ invariant: verdict.invariant,
454
+ reason: verdict.reason,
455
+ });
456
+ }
457
+ function patchFetch() {
458
+ if (typeof globalThis.fetch !== "function")
459
+ return;
460
+ if (globalThis.fetch.__bounded)
461
+ return;
462
+ const orig = globalThis.fetch.bind(globalThis);
463
+ realFetch = orig;
464
+ state_1.state.originalFetch = orig;
465
+ const patched = function fetch(input, init) {
466
+ if (!state_1.state.enabled)
467
+ return orig(input, init);
468
+ let meta;
469
+ let effInit = init;
470
+ try {
471
+ meta = preprocess(input, init);
472
+ if (meta?.cleanedInit)
473
+ effInit = meta.cleanedInit;
474
+ }
475
+ catch (err) {
476
+ (0, state_1.noteInternalError)("fetch.preprocess", err);
477
+ meta = undefined;
478
+ effInit = init;
479
+ }
480
+ // ESCORT (Mode A, T9): a matched escorted route makes ONE verdict round-trip,
481
+ // then fires from THIS process (or throws a rail-shaped local error). Non-
482
+ // escorted routes never reach here (escortFor is a boolean check when escort
483
+ // mode is off) — the observe hot path below is unchanged (U2).
484
+ if (meta) {
485
+ let cfg;
486
+ try {
487
+ cfg = escortFor(meta);
488
+ }
489
+ catch (err) {
490
+ (0, state_1.noteInternalError)("fetch.escortFor", err);
491
+ cfg = undefined;
492
+ }
493
+ if (cfg)
494
+ return runEscort(orig, input, effInit, meta, cfg);
495
+ }
496
+ const start = performance.now();
497
+ let p;
498
+ try {
499
+ p = orig(input, effInit);
500
+ }
501
+ catch (err) {
502
+ // Synchronous throw from fetch (bad args): record nothing, preserve it.
503
+ throw err;
504
+ }
505
+ if (!meta)
506
+ return p;
507
+ const m = meta;
508
+ return p.then((res) => {
509
+ try {
510
+ finalize(m, start, res);
511
+ }
512
+ catch (err) {
513
+ (0, state_1.noteInternalError)("fetch.finalize", err);
514
+ }
515
+ return res;
516
+ }, (err) => {
517
+ try {
518
+ finalize(m, start, undefined);
519
+ }
520
+ catch (e) {
521
+ (0, state_1.noteInternalError)("fetch.finalize", e);
522
+ }
523
+ throw err;
524
+ });
525
+ };
526
+ patched.__bounded = true;
527
+ globalThis.fetch = patched;
528
+ }
529
+ function unpatchFetch() {
530
+ if (realFetch && globalThis.fetch?.__bounded) {
531
+ globalThis.fetch = realFetch;
532
+ }
533
+ shapeBodyBudget.clear();
534
+ }
@@ -0,0 +1,2 @@
1
+ export declare function patchHttp(): void;
2
+ export declare function unpatchHttp(): void;