@bounded-sh/observe
v0.1.2
Published
Bounded observe shim for Node: intercepts fetch + http/https egress, attributes actors, reports metadata-only events to the Bounded observe ingest. Never breaks the app.
Readme
@bounded-sh/observe — Bounded observe shim for Node
CP1 origin for the observe/enforce project (SPEC-OBSERVE-ENFORCE-CUSTODY.md
§3.1a). Intercepts your service's egress — globalThis.fetch (undici) and
http/https.request — and reports metadata-only event batches to the
Bounded observe ingest. Dependency-free at runtime. Never breaks the app:
every shim code path is wrapped; original behavior is always preserved;
observe is always fail-open (Bounded down ⇒ nothing changes for your app, T9).
Install
npm install @bounded-sh/observeconst { init, middleware, runAs } = require("@bounded-sh/observe");
init({
ingestBase: "https://observe-ingest.buildwithtarobase.workers.dev",
token: process.env.BOUNDED_SENSOR_TOKEN, // obs1.<keyId>.<sig>
org: "your-org",
});That's the whole install: call init() once, as early as possible (before
other modules grab references to fetch). ESM: import { init } from
"@bounded-sh/observe".
Actor context — who did it
Attribution rides the report only; nothing is ever added to your outbound
requests (any X-Bounded-* header is actively stripped from egress and
used purely as attribution fallback).
// Per-request (Express or Hono): maps your session to an actor for the whole
// request's async chain.
app.use(middleware()); // default: req.session.user / req.user
app.use(middleware((req) => ({ actor: req.session.user.id, kind: "human" })));
// Explicit scope (bots, jobs, agents):
await runAs({ actor: "agent:support-1", kind: "agent", onBehalfOf: customerId }, async () => {
await stripe.refunds.create({ charge, amount }); // reported as agent:support-1
});Actor ids should be opaque internal ids, never emails (L9 — the ingest
scrubber redacts email-shaped values as suspected PII). Unattributed calls are
still captured (they roll up under the unattributed pseudo-actor).
What's captured vs. NOT (PII posture)
Captured (metadata only, per event): destination host, templated path
(UUIDs / numeric ids / hashes / vendor ids become {id} before anything
leaves your process), method, status, duration, byte counts, actor context.
Manifest-recognized routes (built-in v0 registry: Stripe
refunds/charges/payment_intents; OpenAI + Anthropic spend) additionally
recognize safe fields only: amounts in cents, opaque ids (ch_…,
cus_…), model names, token counts. As of envelope v2 these ride the wire
under rec.fields (a bounded, one-level object of primitive SAFE values) —
rec{rail, action, registryVersion, fields} — and drive deterministic action
stories on the dashboard. They also remain available locally via the onEvent
hook. Server-side, ingest re-checks rec.fields KEYS against the L2 denylist
and scrubs the VALUES (L4), so a leaked email/PAN never lands raw.
NOT captured, ever:
- request/response bodies (unrecognized routes: top-level field names/types only, on the first sighting of a shape);
- query-string values (names only, on shape samples);
- headers (only scanned to strip
X-Bounded-*); - prompts/completions/messages of LLM calls (not in any safe-field list);
- PII-named fields — hard denylist (L2):
email,card*,*_name,password,*token(auth-shaped; token counts are fine),address,ssn,dob, … are compiled into the shim and re-checked at extraction time. Even a malicious/buggy manifest listingcustomer_emailcannot capture it, and even the names of PII-shaped fields are dropped from shape samples.
Fail-safe: no/invalid manifest ⇒ metadata-only mode. Server side, ingest
re-filters (L3) and value-scrubs (L4) independently — the shim never sends
org/sensor (server-stamped from your sensor key), scrubbed, or any
unknown field.
Honest counts: if the shim ever drops events (bounded memory, backpressure),
the drop count is reported on the next successful event (dropped) and
surfaces downstream as completeness flags.
Event classes (SPEC §3.2)
| class | when | notes |
|---------|------------------------------------------|-------|
| action | recognized route, 2xx | rec{rail, action, registryVersion, fields} (v2 rec.fields = SAFE values) |
| error | recognized route, non-2xx / network fail | rec{…, fields}; status 0 = never completed |
| shape | unrecognized route, first sighting | deduped in-memory, rate-capped, names/types only |
| counter | manifest counters matchers + hot unrecognized GETs (> 60 calls/min, exact from call #1) | one event per (route, minute): exact count, summed dur/bytes, class-representative status |
Kill switch
BOUNDED_OBSERVE_DISABLED=1
- at process start: nothing is patched at all;
- at runtime: observation stops within one flush tick (≤2s). Clearing the env resumes it.
Reporting behavior
Async batches (≤500 events or 2s), fire-and-forget with a 5s send timeout,
single in-flight send, one retry for transient failures, bounded queue
(default 5000 events) with drop-and-count on overflow. shutdown() performs
a final drain (call it on SIGTERM to flush the tail). Hot-path overhead is
microseconds (measured in test/perf.test.ts; U2 target <1ms p99).
Supported environments
- Node 18 / 20 / 22 server apps (fetch + http/https interception).
- Vercel / Next.js Node runtime: supported (call
init()ininstrumentation.tsor the server entry). - Edge runtimes (Cloudflare Workers, Vercel Edge, Deno Deploy): the root
entry cannot load there (it needs
node:async_hooks,node:crypto, and http/https patching) — use the@bounded-sh/observe/edgesubpath instead (below).
Edge emitter (@bounded-sh/observe/edge)
For code that already sits at a chokepoint (a Worker proxy, an API gateway) and wants to REPORT events rather than intercept egress. No queue, no timers, no patching — one fire-and-forget envelope-v2 POST per call:
import { emitEvent } from "@bounded-sh/observe/edge";
emitEvent(
{ ingestUrl: env.BOUNDED_INGEST_URL, sensorToken: env.BOUNDED_SENSOR_TOKEN },
{
class: "action",
actor: { id: tenantId, kind: "service", grade: "attested" },
dest: { host: "api.anthropic.com", pathTemplate: "/v1/messages", method: "POST" },
status: 200, dur_ms: 0, bytes: { i: 0, o: 0 },
rec: { rail: "llm-gateway", action: "acme.tenant.aiRun", registryVersion: "acme-proxy",
fields: { actualCents, "usage.input_tokens": inTok, "usage.output_tokens": outTok } },
},
ctx, // optional Workers ExecutionContext — the POST rides ctx.waitUntil
);Posture: NO-OP unless both ingestUrl and sensorToken are present (delete
the secret = kill switch); postEvent never rejects; the POST is bounded by
timeoutMs (default 2000 ms); org/sensor are never sent — the ingest
stamps both server-authoritatively from the sensor key.
Notes: http/https path records duration to response headers and takes
bytes.o from Content-Length (0 when chunked); response-field recognition
(LLM token counts) is fetch-path only.
Config quick reference
init({
ingestBase, token, // required
org, // manifest polling scope
sessionMapper, // default mapper for middleware()
aliasHosts, // {"127.0.0.1:4242": "api.stripe.com"} — dev/demo
flushIntervalMs: 2000, sendTimeoutMs: 5000, maxQueueEvents: 5000,
counterPromoteThreshold: 60, // unrecognized-GET calls/min -> counter class
shapesPerMinute: 30, // new-shape rate cap
manifest, // override (tests); invalid -> built-in (fail-safe)
disableManifestPoll, manifestPollMs,
debug, onEvent, // debug hook: (wireEvent, recognizedFields)
});Manifest (living capture policy, §3.1g)
The shim polls <ingestBase>/v1/manifest?org=… every 60s (T1: capture policy
changes reach every interceptor without re-install). The endpoint doesn't
exist yet — 404/invalid keeps the built-in manifest. Signed-manifest
verification is a marked TODO in src/manifest.ts; until it lands, the
built-in registry is the trust anchor.
Development
npm run build·npm test(76 tests: interception, templating, recognizers, counters, ALS context, header stripping, kill switch, L1/L2 PII behavior, envelope contract vs the realobserve-sharedvalidator, perf, and an integration run of the sample app in~/bounded/observe-sample-app).npm run test:live— LIVE prod verification: mints a dev sensor key (needs/tmp/observe-admin-secret.txtor$OBSERVE_ADMIN_SECRET), runs the sample app against prod ingest, asserts exact rollup deltas via the consumer's/internal/rollup/acme-demo.- Wire contract:
src/envelope.tsmirrors the versioned envelope (v2) frompackages/cdk/cloudflare/observe-shared;test/envelope-contract.test.tsfails on any drift (types + constants + runtime validation), including that every emitted v2 event (withrec.fields) passes the real ingest validator with ZERO stripped fields.
