From 085000c4fc4bbd2fc1a1d92227977aab194f51a2 Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Tue, 25 Aug 2026 23:36:11 +0530 Subject: [PATCH 1/2] feat(runtime-node): auto-flush on process exit + PII redaction for custom attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - installAutterAutoFlush(): force-flushes all exporters on beforeExit/SIGINT/ SIGTERM so a forgotten shutdown() no longer silently drops buffered telemetry. Installed by default via initAutterServer({ autoFlush }); coexists with the app's own signal handlers and never changes their exit path. Timeout-bounded, second signal exits immediately, conventional 130/143 exit codes. - redactAttributes()/makeSafeCapture(): masks emails, JWTs, sk-/ghp_/AWS/Slack tokens, bearer headers, scheme://user:pass@ URLs and sensitive-keyed attributes before they leave the process — closing the server-side gap to match the browser relay's payload whitelist. On by default ({ redactAttributes: false } opts out); covers captureException, captureMessage, withProcessSpan and LLM attributes. - debug mode (debug: true or AUTTER_DEBUG=1): 'exported N span(s)' lines plus a stderr warning when the process exits with unconfirmed captures. - node:test suite (17 tests) incl. an e2e asserting redaction on the wire; wired into CI. Test tokens are fragment-assembled so secret scanners don't mistake fixtures for real credentials. --- docs/ARCHITECTURE.md | 6 + docs/GETTING-STARTED.md | 6 +- packages/runtime-node/README.md | 69 ++++ packages/runtime-node/package.json | 3 +- packages/runtime-node/src/index.ts | 12 + packages/runtime-node/src/lifecycle.ts | 312 ++++++++++++++++++ packages/runtime-node/src/redact.ts | 198 +++++++++++ packages/runtime-node/src/server.ts | 171 ++++++++-- packages/runtime-node/test/autoflush.test.mjs | 98 ++++++ .../test/fixtures/before-exit.mjs | 19 ++ .../test/fixtures/e2e-redaction.mjs | 24 ++ .../test/fixtures/sigterm-coexists.mjs | 31 ++ .../test/fixtures/sigterm-double.mjs | 19 ++ .../test/fixtures/sigterm-sole.mjs | 25 ++ packages/runtime-node/test/redact.test.mjs | 126 +++++++ 15 files changed, 1095 insertions(+), 24 deletions(-) create mode 100644 packages/runtime-node/src/lifecycle.ts create mode 100644 packages/runtime-node/src/redact.ts create mode 100644 packages/runtime-node/test/autoflush.test.mjs create mode 100644 packages/runtime-node/test/fixtures/before-exit.mjs create mode 100644 packages/runtime-node/test/fixtures/e2e-redaction.mjs create mode 100644 packages/runtime-node/test/fixtures/sigterm-coexists.mjs create mode 100644 packages/runtime-node/test/fixtures/sigterm-double.mjs create mode 100644 packages/runtime-node/test/fixtures/sigterm-sole.mjs create mode 100644 packages/runtime-node/test/redact.test.mjs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b9bfed9..0d0bd86 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -214,6 +214,12 @@ counters, not an analytics event store). Forbidden at the schema level (rejected/stripped): full URLs with query strings, cookies, DOM content, form values, request headers/bodies, emails. +Server-side custom attributes are guarded at the source instead: the Node +SDK masks email/token/credential-shaped values and sensitive-keyed +attributes before export (`redactAttributes`, on by default), so OTLP spans +never carry a stray `user.email` even though the OTLP schema itself accepts +free-form attributes. + ## Sink webhook (v1) When `AUTTER_SINK_URL` is set, each ingest batch POSTs: diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 120ee2a..f688984 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -45,7 +45,11 @@ you from zero to seeing data in ClickHouse. | LLM usage & cost | — | `withLlmCall()` / Vercel AI SDK telemetry / GenAI semconv — always 100% (model, tokens, cost) | **What is never sent:** cookies, DOM content, form values, request/response -bodies, headers, emails, full URLs with query strings. +bodies, headers, emails, full URLs with query strings. On the backend, +custom attributes are additionally scrubbed before export: values that look +like emails/tokens/credentials and attributes with sensitive keys +(`password`, `api_key`, …) are masked by default (`redactAttributes`), so a +stray `captureException(err, { "user.email": … })` doesn't leak PII. ## 2. Run the ingester diff --git a/packages/runtime-node/README.md b/packages/runtime-node/README.md index a13d431..1b5a881 100644 --- a/packages/runtime-node/README.md +++ b/packages/runtime-node/README.md @@ -89,6 +89,8 @@ Defaults (cheap by construction): | Healthy traces | 1% head sampling (`traceSampleRate`) | | Request metrics | exported every 60 s | | Logs | not collected | +| PII in custom attributes | redacted before export (`redactAttributes`) | +| Forgotten `shutdown()` | exporters still flushed on exit (`autoFlush`) | **Error-linked trace retention.** Errors export at 100% while traces are head-sampled — on its own that strands a retained error without the trace @@ -109,6 +111,30 @@ import { ExpressInstrumentation } from "@opentelemetry/instrumentation-express"; initAutterServer({ ..., instrumentations: [new ExpressInstrumentation()] }); ``` +### Lifecycle: never lose telemetry to a forgotten shutdown() + +Telemetry is batched (errors every ~2 s, healthy traces every ~5 s, metrics +every 60 s) — so exiting without flushing loses whatever is still buffered. +`initAutterServer` therefore installs an exit flush **by default**: on +`beforeExit`, SIGINT, and SIGTERM it force-flushes every exporter, then lets +your process die as it would have (conventional 130/143 codes). If your own +code also handles those signals, Autter only flushes alongside it and never +touches your exit path. Opt out with `autoFlush: false`. + +Prefer explicit control? Do the same yourself and get a handle back: + +```ts +import { installAutterAutoFlush } from "@autter/runtime-node"; + +const handle = installAutterAutoFlush(); // uses the active server's exporters +// …later: handle.flush("deploy-drain") or handle.dispose() +``` + +If the process still exits with captures that were never confirmed +exported (e.g. a flush timed out), you get a one-line stderr warning — not +silent loss. While wiring things up, set `debug: true` (or `AUTTER_DEBUG=1`) +to see `[autter] exported N span(s)` lines on stderr as batches leave. + Note on usage rollups: requests are counted from the `http.server.duration` metric (100% accurate) and additionally from sampled server spans. At the default 1% sampling the span contribution is negligible; if you set @@ -116,6 +142,49 @@ default 1% sampling the span contribution is negligible; if you set Tail-retained error traces don't distort this: their spans carry `autter.tail_retained` and the ingester keeps them out of span-fed rollups. +### Privacy: custom attributes are redacted before they leave the process + +The browser relay whitelist-sanitises everything a client posts — but the +server tracker accepts free-form attributes from *your* code, where a stray +`captureException(err, { "user.email": … })` would otherwise go out +verbatim. Custom attributes are therefore scrubbed at capture time by +default: + +- values that look like emails, JWTs, `sk-…`/`ghp_…`/AWS/Slack tokens, + `Bearer …` headers, or `scheme://user:pass@host` URLs are masked; +- attributes whose **key** looks sensitive (`password`, `token`, `secret`, + `api_key`, `authorization`, `cookie`, `ssn`, `card_number`, …) are masked + wholesale; +- non-sensitive keys and primitives pass through untouched, so grouping and + dashboards keep working. + +Disable or extend it per service: + +```ts +initAutterServer({ + ..., + // redactAttributes: false, // opt out entirely + redactAttributes: { + additionalKeyPatterns: ["employee_id"], + additionalValuePatterns: [/^ACC-\d+$/], + }, +}); +``` + +Libraries that must never forward PII regardless of host configuration can +wrap once: + +```ts +import { makeSafeCapture } from "@autter/runtime-node"; +const safe = makeSafeCapture(); +safe.captureException(err, { "user.email": email }); // masked before export +``` + +The raw primitive is exported too (`redactAttributes(attrs, options)`). +This closes the server-side gap to match the browser relay's payload +whitelist; it is best-effort scrubbing of obvious PII shapes, not a DLP +engine — keep secrets out of attributes in the first place. + ## 3. LLM tracing `initAutterServer` initialises LLM tracing automatically: any GenAI span — diff --git a/packages/runtime-node/package.json b/packages/runtime-node/package.json index 24cbdcc..2ae768f 100644 --- a/packages/runtime-node/package.json +++ b/packages/runtime-node/package.json @@ -23,7 +23,8 @@ "directory": "packages/runtime-node" }, "scripts": { - "build": "tsup src/index.ts --format esm,cjs --dts --target node20 --clean" + "build": "tsup src/index.ts --format esm,cjs --dts --target node20 --clean", + "test": "npm run build && node --test test/*.test.mjs" }, "dependencies": { "@opentelemetry/api": "^1.9.0", diff --git a/packages/runtime-node/src/index.ts b/packages/runtime-node/src/index.ts index 4c79c8a..54d6aa7 100644 --- a/packages/runtime-node/src/index.ts +++ b/packages/runtime-node/src/index.ts @@ -12,6 +12,7 @@ export { withLlmCall, trackLlmCall, emitLlmSelftestTrace, + makeSafeCapture, type AutterServerOptions, type AutterServer, type AutterSeverity, @@ -19,7 +20,18 @@ export { type LlmCallHandle, type LlmUsage, type TrackedLlmCall, + type SafeCapture, } from "./server.js"; +export { + installAutterAutoFlush, + type AutoFlushHandle, + type AutoFlushOptions, + type FlushTarget, +} from "./lifecycle.js"; +export { + redactAttributes, + type RedactOptions, +} from "./redact.js"; export { instrumentLlmClient, type InstrumentLlmOptions, diff --git a/packages/runtime-node/src/lifecycle.ts b/packages/runtime-node/src/lifecycle.ts new file mode 100644 index 0000000..880d44b --- /dev/null +++ b/packages/runtime-node/src/lifecycle.ts @@ -0,0 +1,312 @@ +import { ExportResultCode, type ExportResult } from "@opentelemetry/core"; +import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"; + +/** + * Lifecycle helpers for the server tracker: + * + * - `installAutterAutoFlush()` — pushes buffered telemetry out on process + * exit (`beforeExit`, SIGINT, SIGTERM) so a forgotten `shutdown()` no + * longer means silent data loss. + * - export counting + debug logging — "exported N spans" visibility and an + * explicit warning when the process exits with unconfirmed captures. + * + * The browser SDK solves the same problem with pagehide/beacon batching; + * Node has no beacon, so we flush what we can reach instead. + */ + +// --------------------------------------------------------------------------- +// Debug logging +// --------------------------------------------------------------------------- + +let debugEnabled = + process.env.AUTTER_DEBUG === "1" || process.env.AUTTER_DEBUG === "true"; + +export interface AutterLogger { + log: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; +} + +export function setDebugMode(enabled: boolean): void { + debugEnabled = enabled; +} + +export function isDebugEnabled(): boolean { + return debugEnabled; +} + +/** `[autter] …` line to stderr, only when debug mode is on. */ +export function debugLog(...args: unknown[]): void { + if (debugEnabled) { + console.error("[autter]", ...args); + } +} + +// --------------------------------------------------------------------------- +// Export accounting — powers "exported N spans" logs and the unflushed-exit +// warning. +// --------------------------------------------------------------------------- + +class TelemetryStats { + /** Captures queued via our APIs and not yet seen leaving an exporter. */ + pendingCaptures = 0; + + markCaptured(count = 1): void { + this.pendingCaptures += count; + } + + /** An exporter batch left the process successfully. */ + onExported(spanCount: number): void { + // Exported batches also contain sampled request spans, so this + // over-credits captures — erring toward "already flushed" keeps the + // exit warning quiet unless data really is still queued. + this.pendingCaptures = Math.max(0, this.pendingCaptures - spanCount); + if (debugEnabled && spanCount > 0) { + debugLog(`exported ${spanCount} span${spanCount === 1 ? "" : "s"}`); + } + } + + markAllFlushed(): void { + this.pendingCaptures = 0; + } +} + +export const telemetryStats = new TelemetryStats(); + +/** + * Wraps an OTLP trace exporter to observe every batch that actually leaves + * the process (periodic batches included — wrapping the span processor would + * only see forced flushes). + */ +export class CountingExporter implements SpanExporter { + constructor(private readonly inner: SpanExporter) {} + + export( + spans: ReadableSpan[], + resultCallback: (result: ExportResult) => void, + ): void { + this.inner.export(spans, (result) => { + if (result.code === ExportResultCode.SUCCESS) { + telemetryStats.onExported(spans.length); + } + resultCallback(result); + }); + } + + shutdown(): Promise { + return this.inner.shutdown(); + } + + forceFlush(): Promise { + return this.inner.forceFlush?.() ?? Promise.resolve(); + } +} + +// --------------------------------------------------------------------------- +// Flush-target registry — initAutterServer registers everything that buffers +// telemetry here, so auto-flush can reach it without reaching into NodeSDK +// internals (NodeSDK exposes shutdown() but no forceFlush()). +// --------------------------------------------------------------------------- + +export interface FlushTarget { + forceFlush(): Promise | unknown; +} + +const flushTargets = new Map(); + +export function registerFlushTarget(key: string, target: FlushTarget): void { + flushTargets.set(key, target); +} + +/** Removes all registered targets — called by shutdown(). */ +export function unregisterFlushTargets(): void { + flushTargets.clear(); +} + +function activeFlushTargets(): FlushTarget[] { + return [...flushTargets.values()]; +} + +// --------------------------------------------------------------------------- +// Auto-flush +// --------------------------------------------------------------------------- + +export interface AutoFlushOptions { + /** + * Targets to flush. Defaults to whatever initAutterServer has registered; + * pass your own for custom pipelines. + */ + targets?: FlushTarget[]; + /** Where lifecycle messages go. Default console. */ + logger?: AutterLogger; + /** Log each successful flush ("flushed telemetry (SIGTERM)"). Default true. */ + log?: boolean; + /** + * Warn on stderr when the process exits with captures that were never + * confirmed exported. Default true. + */ + warnOnUnflushedExit?: boolean; + /** + * Max wall-clock time a single flush may take before giving up (and, on + * signals, proceeding to exit anyway). Default 3000 ms. + */ + timeoutMs?: number; + /** Signals handled besides the defaults. Default ["SIGINT", "SIGTERM"]. */ + signals?: string[]; +} + +export interface AutoFlushHandle { + /** Force-flush now. Safe to call repeatedly; concurrent calls coalesce. */ + flush(reason?: string): Promise; + /** Remove every listener this install added. */ + dispose(): void; +} + +const DEFAULT_SIGNALS = ["SIGINT", "SIGTERM"] as const; +const SIGNAL_EXIT_CODES: Record = { + SIGINT: 130, + SIGTERM: 143, +}; + +/** + * Flush all exporters when the process is going away: + * + * - `beforeExit` — event-loop drained naturally: force-flush, then hand the + * loop back (a kept-alive timer makes sure the async flush finishes + * inside the beforeExit window). + * - SIGINT / SIGTERM — flush, then exit with the conventional code (130 / + * 143). If YOUR code also listens to the signal, we only flush + * concurrently and let your handler decide the exit — installing this + * never changes an existing graceful-shutdown path. A second signal + * always exits immediately. + * + * Idempotent per options object; `initAutterServer` installs it by default + * (`autoFlush: false` opts out). + */ +export function installAutterAutoFlush( + options: AutoFlushOptions = {}, +): AutoFlushHandle { + const logger = options.logger ?? console; + const log = options.log !== false; + const warnOnUnflushedExit = options.warnOnUnflushedExit !== false; + const timeoutMs = Math.max(100, options.timeoutMs ?? 3_000); + const signals = options.signals ?? DEFAULT_SIGNALS; + let disposed = false; + + let inFlight: Promise | null = null; + + const doFlush = async (): Promise => { + const targets = options.targets ?? activeFlushTargets(); + if (targets.length === 0) return true; + let timer: NodeJS.Timeout | undefined; + const timedOut = new Promise((resolve) => { + timer = setTimeout(() => resolve(false), timeoutMs); + }); + const drained = Promise.allSettled( + targets.map((target) => target.forceFlush()), + ).then(() => true); + const ok = await Promise.race([drained, timedOut]); + clearTimeout(timer); + if (ok) { + telemetryStats.markAllFlushed(); + if (log) { + logger.log("[autter] telemetry flushed"); + } + } else { + logger.error( + `[autter] flush did not finish within ${timeoutMs}ms — some telemetry may be lost`, + ); + } + return ok; + }; + + const flush = (reason = "exit"): Promise => { + if (disposed) return Promise.resolve(false); + if (!inFlight) { + inFlight = doFlush().finally(() => { + inFlight = null; + }); + } + if (isDebugEnabled()) debugLog(`auto-flush requested on ${reason}`); + return inFlight; + }; + + const signalCounts = new Map(); + + const onSignal = (signal: string): void => { + const count = (signalCounts.get(signal) ?? 0) + 1; + signalCounts.set(signal, count); + const exitCode = SIGNAL_EXIT_CODES[signal] ?? 1; + if (count > 1) { + // Second Ctrl+C / kill: the user wants out, don't hold the door. + process.exit(exitCode); + } + // -1 because our own listener is already attached at this point. + const otherHandlers = process.listenerCount(signal) - 1; + if (otherHandlers > 0) { + // The app owns this signal's exit path (graceful drain etc.) — + // opportunistically flush alongside it and stay out of the way. + void flush(signal); + return; + } + void flush(signal).finally(() => { + process.exit(exitCode); + }); + }; + + // beforeExit re-fires every time the loop drains again — including after + // OUR keep-alive timer is cleared — so flush eagerly on the first drain + // (covering buffers we don't count, like sampled request spans and the + // metric interval) and afterwards only when captures are actually + // pending. The cap is a runaway guard, not an expected path. + const MAX_BEFORE_EXIT_FLUSHES = 10; + let beforeExitFlushes = 0; + + const onBeforeExit = (): void => { + if (disposed) return; + const targets = options.targets ?? activeFlushTargets(); + const worthIt = + beforeExitFlushes === 0 + ? targets.length > 0 + : telemetryStats.pendingCaptures > 0; + if (!worthIt || beforeExitFlushes >= MAX_BEFORE_EXIT_FLUSHES) return; + beforeExitFlushes++; + // beforeExit does not wait for promises: pin the event loop until the + // flush settles (or times out), then let the process die naturally. + const keepAlive = setTimeout(() => {}, timeoutMs); + void flush("beforeExit").finally(() => clearTimeout(keepAlive)); + }; + + const onExit = (): void => { + if (!warnOnUnflushedExit || disposed) return; + const pending = telemetryStats.pendingCaptures; + if (pending > 0) { + logger.error( + `[autter] process exiting with ~${pending} captured item(s) never confirmed exported — await autter.shutdown() on graceful shutdown`, + ); + } + }; + + const signalHandlers = new Map void>(); + + process.on("beforeExit", onBeforeExit); + for (const signal of signals) { + const handler = () => onSignal(signal); + signalHandlers.set(signal, handler); + process.on(signal, handler); + } + process.on("exit", onExit); + + return { + flush, + dispose(): void { + disposed = true; + process.removeListener("beforeExit", onBeforeExit); + process.removeListener("exit", onExit); + for (const [signal, handler] of signalHandlers) { + process.removeListener(signal, handler); + } + signalHandlers.clear(); + }, + }; +} diff --git a/packages/runtime-node/src/redact.ts b/packages/runtime-node/src/redact.ts new file mode 100644 index 0000000..93431a7 --- /dev/null +++ b/packages/runtime-node/src/redact.ts @@ -0,0 +1,198 @@ +import type { Attributes } from "@opentelemetry/api"; + +/** + * Server-side attribute redaction. The browser relay whitelist-sanitises + * everything a client posts (see relay.ts) — but custom attributes handed to + * `captureException` / `captureMessage` left the process verbatim. This module + * closes that gap: emails, tokens, API keys, and credentials embedded in + * string values are masked, and attributes whose NAME looks sensitive are + * masked wholesale, before anything is exported. + */ + +type AttrValue = + | string + | number + | boolean + | Array + | object; + +export interface RedactOptions { + /** + * Extra patterns matched against lower-cased attribute KEYS; a match + * masks the whole value. Strings become case-insensitive substring + * patterns. Extends the built-in list. + */ + additionalKeyPatterns?: (RegExp | string)[]; + /** + * Extra patterns scrubbed inside string VALUES (same semantics as the + * built-in email/token patterns). Extends the built-in list. + */ + additionalValuePatterns?: (RegExp | string)[]; + /** Replacement token. Default "[redacted]". */ + mask?: string; + /** + * Scrub email-shaped substrings from ALL string values, not just + * sensitive keys. Default true. + */ + scrubEmailValues?: boolean; +} + +// Tested against the lower-cased KEY. Deliberately anchored where a loose +// substring would over-redact ("card" must not eat "discard", +// "author" must not eat "author_id"). +const SENSITIVE_KEY_PATTERNS: RegExp[] = [ + /e-?mail/, + /pass(word|wd|phrase)|^pass$/, + /token/, + /secret/, + /credential/, + /(api|access|secret|private|consumer|client|signing|encryption)-?[_.]?key/, + /^-?x?-?authorization$|^auth(-|_|$)|bearer/, + /cookie/, + /phone|msisdn/, + /\bssn\b|social[-_ ]?security/, + /cvv|cvc|card([-_. ]?(number|num|no))?$/, + /credit[-_.]?card/, + /connection[-_.]?string/, + /recovery[-_.]?code|\botp\b|magic[-_.]?link/, +]; + +// Scrubbed INSIDE string values (matched substrings are replaced, the rest +// of the value survives — useful context like a stack frame stays readable). +const PRIVATE_KEY_BLOCK_RE = + /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY-----|$)/g; +const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; +const JWT_RE = /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g; +const OPENAI_KEY_RE = /\bsk-[A-Za-z0-9]{20,}\b/g; +const GITHUB_TOKEN_RE = /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g; +const AWS_KEY_RE = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g; +const SLACK_TOKEN_RE = /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g; +const BEARER_RE = /\bbearer\s+[A-Za-z0-9._~+/=-]{10,}/gi; +// postgres://user:password@host — credentials gone, host kept. +const URL_CREDENTIALS_RE = + /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/:@]+:[^\s/@]+@/gi; + +interface CompiledRedactor { + keyPatterns: RegExp[]; + valuePatterns: RegExp[]; + urlCredentials: RegExp; + privateKeyBlock: RegExp; + mask: string; + scrubEmailValues: boolean; +} + +function toCaseInsensitive(pattern: RegExp | string): RegExp { + return typeof pattern === "string" + ? new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") + : new RegExp(pattern.source, pattern.flags.includes("i") ? pattern.flags : `${pattern.flags}i`); +} + +function compile(options?: RedactOptions): CompiledRedactor { + const keyPatterns = [...SENSITIVE_KEY_PATTERNS]; + const valuePatterns: RegExp[] = [ + JWT_RE, + OPENAI_KEY_RE, + GITHUB_TOKEN_RE, + AWS_KEY_RE, + SLACK_TOKEN_RE, + BEARER_RE, + ]; + if (options?.additionalKeyPatterns) { + for (const p of options.additionalKeyPatterns) { + keyPatterns.push(toCaseInsensitive(p)); + } + } + if (options?.additionalValuePatterns) { + for (const p of options.additionalValuePatterns) { + if (!valuePatterns.some((existing) => existing.source === (typeof p === "string" ? p : p.source))) { + valuePatterns.push(typeof p === "string" ? new RegExp(p, "gi") : p); + } + } + } + return { + keyPatterns, + valuePatterns, + urlCredentials: URL_CREDENTIALS_RE, + privateKeyBlock: PRIVATE_KEY_BLOCK_RE, + mask: options?.mask ?? "[redacted]", + scrubEmailValues: options?.scrubEmailValues !== false, + }; +} + +function redactString(value: string, r: CompiledRedactor): string { + let out = value.replace(r.privateKeyBlock, r.mask); + for (const re of r.valuePatterns) { + out = out.replace(re, r.mask); + } + out = out.replace(r.urlCredentials, "$1" + r.mask + "@"); + if (r.scrubEmailValues) { + out = out.replace(EMAIL_VALUE_RE, r.mask); + } + return out; +} + +function redactValue(value: unknown, r: CompiledRedactor, depth: number): unknown { + if (typeof value === "string") return redactString(value, r); + if (Array.isArray(value)) { + if (depth <= 0) return value; + return value.map((item) => redactValue(item, r, depth - 1)); + } + // Defensive: OTLP attributes are flat, but callers hand us arbitrary + // objects — walk one more level so nothing sensitive hides inside. + if (typeof value === "object" && value !== null && depth > 0) { + const out: Record = {}; + for (const [k, v] of Object.entries(value as Record)) { + out[k] = isSensitiveKey(k, r) ? r.mask : redactValue(v, r, depth - 1); + } + return out; + } + return value; +} + +function isSensitiveKey(key: string, r: CompiledRedactor): boolean { + const lowered = key.toLowerCase(); + return r.keyPatterns.some((re) => re.test(lowered)); +} + +/** + * Return a copy of `attributes` with PII/secrets masked. Never mutates the + * input; non-string primitives pass through untouched; `undefined` values + * are dropped (OpenTelemetry rejects them). + */ +export function redactAttributes( + attributes?: Attributes | null, + options?: RedactOptions, +): Attributes { + const r = compile(options); + return redactWith(attributes, r, 4); +} + +function redactWith( + attributes: Attributes | null | undefined, + r: CompiledRedactor, + maxDepth: number, +): Attributes { + const out: Attributes = {}; + if (!attributes) return out; + for (const [key, value] of Object.entries(attributes)) { + if (value === undefined) continue; + out[key] = isSensitiveKey(key, r) + ? r.mask + : (redactValue(value, r, maxDepth) as Attributes[string]); + } + return out; +} + +/** + * Compile a redactor once for a hot path — initAutterServer builds one from + * its options and reuses it for every capture instead of recompiling. + */ +export function makeRedactor( + options?: boolean | RedactOptions, +): (attributes?: Attributes | null) => Attributes { + if (options === false) { + return (attributes) => ({ ...(attributes ?? {}) }); + } + const r = compile(options === true ? undefined : options); + return (attributes) => redactWith(attributes, r, 4); +} diff --git a/packages/runtime-node/src/server.ts b/packages/runtime-node/src/server.ts index d4b041d..7594e55 100644 --- a/packages/runtime-node/src/server.ts +++ b/packages/runtime-node/src/server.ts @@ -38,6 +38,23 @@ import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION, } from "@opentelemetry/semantic-conventions"; +import { + CountingExporter, + installAutterAutoFlush, + isDebugEnabled, + debugLog, + registerFlushTarget, + telemetryStats, + unregisterFlushTargets, + type AutoFlushHandle, + setDebugMode, + type FlushTarget, +} from "./lifecycle.js"; +import { + makeRedactor, + redactAttributes, + type RedactOptions, +} from "./redact.js"; /** * Curated OpenTelemetry setup for Autter Runtime — deliberately NOT the @@ -90,6 +107,29 @@ export interface AutterServerOptions { metricIntervalMs?: number; /** Capture crashing exceptions via process.uncaughtExceptionMonitor (default true). */ captureGlobalErrors?: boolean; + /** + * Flush all exporters when the process exits (`beforeExit`, SIGINT, + * SIGTERM) so a forgotten `shutdown()` doesn't silently drop buffered + * telemetry. If your code also handles the signal, Autter only flushes + * alongside it and never changes your exit path. Default true. + */ + autoFlush?: boolean; + /** + * Mask PII/secrets in custom attributes before they leave the process: + * emails, tokens (JWT, sk-, gh_, AWS, Slack), bearer headers, + * `scheme://user:pass@` URLs, and anything whose attribute KEY looks + * sensitive (password/token/secret/cookie/…). Mirrors the browser + * relay's whitelist sanitiser. `true`/undefined = defaults; pass a + * RedactOptions object to extend the patterns; `false` disables. + * Default true. + */ + redactAttributes?: boolean | RedactOptions; + /** + * Verbose lifecycle logging to stderr: "exported N spans", flushes and + * auto-flush activity — handy while wiring the SDK up. Also enabled by + * the AUTTER_DEBUG=1 environment variable. Default false. + */ + debug?: boolean; /** * Record LLM/GenAI spans (`gen_ai.*` semconv, Vercel AI SDK `ai.*`, * `withLlmCall`) at 100% regardless of `traceSampleRate`, so every model @@ -484,7 +524,9 @@ function llmBaseAttributes(info: LlmCallInfo): Attributes { "gen_ai.request.model": info.model, ...(info.userId ? { "autter.user_id": info.userId } : {}), ...(info.sessionId ? { "autter.session_id": info.sessionId } : {}), - ...info.attributes, + // Caller-supplied extras go through redaction like every other + // custom attribute family (prompts can contain emails/keys). + ...(info.attributes ? activeRedactor(info.attributes) : {}), }; } @@ -656,6 +698,10 @@ let active: AutterServer | null = null; * server is active so withLlmCall/withProcessSpan bypass head sampling. */ let activeAlwaysOnProvider: BasicTracerProvider | null = null; +/** Compiled attribute redactor — defaults until initAutterServer applies its + * own configuration. Used by every capture path including LLM attributes. */ +let activeRedactor = makeRedactor(true); + export function initAutterServer(options: AutterServerOptions): AutterServer { if (active) return active; @@ -666,6 +712,12 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { const headers = { authorization: `Bearer ${options.apiKey}` }; const environment = options.environment ?? process.env.NODE_ENV ?? "production"; + // Debug mode: option OR AUTTER_DEBUG env (lifecycle seeds itself from the + // env at import time — never clobber an env-enabled session here). + if (isDebugEnabled() || options.debug === true) setDebugMode(true); + debugLog(`initialising service=${options.service} endpoint=${endpoint}`); + activeRedactor = makeRedactor(options.redactAttributes ?? true); + const resource = new Resource({ [ATTR_SERVICE_NAME]: options.service, ...(options.release ? { [ATTR_SERVICE_VERSION]: options.release } : {}), @@ -694,33 +746,40 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { const errorTraceBuffer = retainOnError ? new ErrorTraceRetentionProcessor( new BatchSpanProcessor( - new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }), + new CountingExporter( + new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }), + ), { scheduledDelayMillis: 2000 }, ), ) : null; + const mainSpanProcessor = new BatchSpanProcessor( + new CountingExporter( + new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }), + ), + ); + const metricReader = new PeriodicExportingMetricReader({ + exporter: new OTLPMetricExporter({ + url: `${endpoint}/v1/metrics`, + headers, + // Deltas, not lifetime totals: the ingester SUMs data points + // into runtime_metrics_1m, and the default (cumulative) + // temporality would re-count every past request on each + // 60 s export. + temporalityPreference: AggregationTemporalityPreference.DELTA, + }), + exportIntervalMillis: options.metricIntervalMs ?? 60_000, + }); + const sdk = new NodeSDK({ resource, sampler, spanProcessors: [ - new BatchSpanProcessor( - new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }), - ), + mainSpanProcessor, ...(errorTraceBuffer ? [errorTraceBuffer] : []), ], - metricReader: new PeriodicExportingMetricReader({ - exporter: new OTLPMetricExporter({ - url: `${endpoint}/v1/metrics`, - headers, - // Deltas, not lifetime totals: the ingester SUMs data points - // into runtime_metrics_1m, and the default (cumulative) - // temporality would re-count every past request on each - // 60 s export. - temporalityPreference: AggregationTemporalityPreference.DELTA, - }), - exportIntervalMillis: options.metricIntervalMs ?? 60_000, - }), + metricReader, instrumentations: [ new HttpInstrumentation({ responseHook: captureExpressRoute }), ...((options.instrumentations ?? []) as never[]), @@ -736,7 +795,9 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { sampler: new AlwaysOnSampler(), spanProcessors: [ new BatchSpanProcessor( - new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }), + new CountingExporter( + new OTLPTraceExporter({ url: `${endpoint}/v1/traces`, headers }), + ), { scheduledDelayMillis: 2000 }, ), ], @@ -752,10 +813,16 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { function captureException(error: unknown, attributes?: Attributes): void { // A captured error makes the surrounding trace worth keeping in full. errorTraceBuffer?.retainActiveTrace(); + telemetryStats.markCaptured(); const isError = error instanceof Error; const message = isError ? error.message : String(error); const span = errorTracer.startSpan(isError ? error.name : "Error", { - attributes: { "autter.severity": "error", ...attributes }, + attributes: { + "autter.severity": "error", + // Redaction is applied here, not at export time: PII must not + // leave the process even if an exporter misbehaves. + ...activeRedactor(attributes), + }, }); if (isError && error.stack) { span.recordException(error); @@ -788,6 +855,7 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { if (severity === "error" || severity === "fatal") { errorTraceBuffer?.retainActiveTrace(); } + telemetryStats.markCaptured(); // Same wire shape as an exception (an event named "exception" with // ERROR status is what the ingester turns into an occurrence), with // autter.severity carrying the level. A synthetic stack (minus this @@ -797,7 +865,7 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { .filter((line, i) => i === 0 || !line.includes("captureMessage")) .join("\n"); const span = errorTracer.startSpan("Message", { - attributes: { "autter.severity": severity, ...attributes }, + attributes: { "autter.severity": severity, ...activeRedactor(attributes) }, }); span.addEvent("exception", { "exception.type": "Message", @@ -815,7 +883,7 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { // Best-effort: the batch may not fully flush before the process dies. process.on("uncaughtExceptionMonitor", (err) => { captureException(err, { "autter.unhandled": true }); - void alwaysOnProvider.forceFlush().catch(() => {}); + void flushTarget.forceFlush(); }); // The async twin of an uncaught exception: a rejected promise with no // `.catch`. Registering this listener also stops Node's default @@ -834,16 +902,40 @@ export function initAutterServer(options: AutterServerOptions): AutterServer { }); } + // Everything that buffers telemetry in-process, reachable as one unit: + // auto-flush and the crash monitor push all of these out together + // (NodeSDK exposes no forceFlush(), but the processors we handed it do). + const flushTarget: FlushTarget = { + forceFlush: async () => { + await Promise.allSettled([ + alwaysOnProvider.forceFlush(), + mainSpanProcessor.forceFlush(), + ...(errorTraceBuffer ? [errorTraceBuffer.forceFlush()] : []), + metricReader.forceFlush(), + ]); + }, + }; + registerFlushTarget("active-server", flushTarget); + + let autoFlushHandle: AutoFlushHandle | null = null; + if (options.autoFlush !== false) { + autoFlushHandle = installAutterAutoFlush(); + } + const server: AutterServer = { captureException, captureMessage, withProcessSpan: (name, fn, attributes) => - runWithSpan(processTracer, name, fn, attributes), + runWithSpan(processTracer, name, fn, activeRedactor(attributes)), withLlmCall: (info, fn) => runLlmSpan(llmTracer, info, fn), trackLlmCall: (call) => recordLlmCall(llmTracer, call), shutdown: async () => { active = null; activeAlwaysOnProvider = null; + autoFlushHandle?.dispose(); + autoFlushHandle = null; + unregisterFlushTargets(); + telemetryStats.markAllFlushed(); await Promise.allSettled([alwaysOnProvider.shutdown(), sdk.shutdown()]); }, }; @@ -931,6 +1023,41 @@ export function captureMessage( span.end(); } +/** Capture surface with guaranteed redaction, regardless of how the host app + * configured initAutterServer — for library authors whose calls must never + * forward PII even when the host disabled it. Routes to the active server + * (or degrades to the global tracer exactly like captureException). */ +export interface SafeCapture { + captureException(error: unknown, attributes?: Attributes): void; + captureMessage( + message: string, + severity?: AutterSeverity, + attributes?: Attributes, + ): void; +} + +/** + * Build a redacting capture wrapper: + * + * const safe = makeSafeCapture(); + * safe.captureException(err, { "user.email": email }); // masked before export + * + * The returned functions have the same signatures as their plain twins; + * every attribute passes through redactAttributes() first. Note that when + * the host has NOT disabled redaction this is belt-and-braces (already on by + * default) — its value is enforcing privacy inside libraries and wrappers. + */ +export function makeSafeCapture(options?: RedactOptions): SafeCapture { + const clean = (attributes?: Attributes): Attributes | undefined => + attributes ? { ...redactAttributes(attributes, options) } : undefined; + return { + captureException: (error, attributes) => + captureException(error, clean(attributes)), + captureMessage: (message, severity, attributes) => + captureMessage(message, severity ?? "warning", clean(attributes)), + }; +} + /** Tracer from the always-on provider (never head-sampled), or the global * provider when initAutterServer hasn't run — so wrappers are safe to call * unconditionally from library code. */ diff --git a/packages/runtime-node/test/autoflush.test.mjs b/packages/runtime-node/test/autoflush.test.mjs new file mode 100644 index 0000000..c6066ab --- /dev/null +++ b/packages/runtime-node/test/autoflush.test.mjs @@ -0,0 +1,98 @@ +import { test, before, after } from "node:test"; +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import http from "node:http"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = (name) => path.join(here, "fixtures", name); + +function runChild(file, env = {}) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [file], { + env: { ...process.env, ...env }, + stdout: "pipe", + stderr: "pipe", + }); + let out = ""; + let err = ""; + child.stdout.on("data", (d) => (out += d)); + child.stderr.on("data", (d) => (err += d)); + child.on("close", (code) => resolve({ code, out, err })); + }); +} + +let collector; +let collectorPort; +let receivedBodies; + +before(async () => { + receivedBodies = []; + collector = http.createServer((req, res) => { + const chunks = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => { + receivedBodies.push(Buffer.concat(chunks).toString("utf8")); + res.writeHead(200, { "content-type": "application/json" }); + res.end("{}"); + }); + }); + await new Promise((resolve) => collector.listen(0, "127.0.0.1", resolve)); + collectorPort = collector.address().port; +}); + +after(() => collector.close()); + +test("SIGTERM with no other handler: flushes then exits 130/143", async () => { + const { code, out } = await runChild(fixtures("sigterm-sole.mjs")); + assert.equal(code, 143); // SIGTERM -> 128 + 15 + assert.match(out, /\[autter\] telemetry flushed/); +}); + +test("second signal exits immediately instead of waiting for a slow flush", async () => { + const started = Date.now(); + const { code } = await runChild(fixtures("sigterm-double.mjs")); + const elapsed = Date.now() - started; + assert.equal(code, 143); + // Flush target alone would hold the process for ~2s; the second SIGTERM + // must cut through well before that. + assert.ok(elapsed < 1_500, `took ${elapsed}ms — second signal did not cut through`); +}); + +test("SIGTERM coexists with the app's own handler — app exit code wins", async () => { + const { code, out } = await runChild(fixtures("sigterm-coexists.mjs")); + assert.equal(code, 0); + assert.match(out, /app graceful drain complete/); +}); + +test("beforeExit flush completes inside the natural-exit window", async () => { + const { code, out } = await runChild(fixtures("before-exit.mjs")); + assert.equal(code, 0); + assert.match(out, /FLUSHED=1/); + assert.ok(!/FLUSHED=2/.test(out), "beforeExit must not loop flushes"); +}); + +test("e2e: captured exception attributes are redacted on the wire", async () => { + const { code, err } = await runChild(fixtures("e2e-redaction.mjs"), { + COLLECTOR_PORT: String(collectorPort), + AUTTER_DEBUG: "1", + }); + assert.equal(code, 143); + + assert.ok(receivedBodies.length > 0, "collector received nothing"); + const wire = receivedBodies.join("\n"); + + // PII must not survive the trip. + assert.ok(!wire.includes("jane.doe@example.com"), "raw email leaked"); + assert.ok(!wire.includes("supersecrettoken123456"), "raw bearer token leaked"); + assert.ok(!wire.includes("eyJhbGciOiJIUzI1NiJ9"), "raw JWT leaked"); + + // Mask present; non-sensitive context intact. + assert.ok(wire.includes("[redacted]"), "expected mask marker"); + assert.ok(wire.includes("o-1"), "non-sensitive attribute was dropped"); + assert.ok(wire.includes("boom: order failed"), "exception message missing"); + + // Debug mode reported exports. + assert.match(err, /exported \d+ span/); +}); diff --git a/packages/runtime-node/test/fixtures/before-exit.mjs b/packages/runtime-node/test/fixtures/before-exit.mjs new file mode 100644 index 0000000..d888562 --- /dev/null +++ b/packages/runtime-node/test/fixtures/before-exit.mjs @@ -0,0 +1,19 @@ +// Child fixture: event loop drains naturally -> beforeExit fires, the async +// flush must complete inside that window, then the process exits 0. +import { installAutterAutoFlush } from "../../dist/index.js"; + +let flushes = 0; +installAutterAutoFlush({ + log: true, + targets: [ + { + async forceFlush() { + await new Promise((r) => setTimeout(r, 40)); + flushes++; + console.log(`FLUSHED=${flushes}`); + }, + }, + ], +}); + +setTimeout(() => {}, 20); diff --git a/packages/runtime-node/test/fixtures/e2e-redaction.mjs b/packages/runtime-node/test/fixtures/e2e-redaction.mjs new file mode 100644 index 0000000..8567860 --- /dev/null +++ b/packages/runtime-node/test/fixtures/e2e-redaction.mjs @@ -0,0 +1,24 @@ +// Child fixture: FULL pipeline e2e — initAutterServer against the parent's +// local collector, capture an exception full of PII, then SIGTERM. Auto-flush +// must push the redacted span out before the process dies. +import { initAutterServer } from "../../dist/index.js"; + +const autter = initAutterServer({ + endpoint: `http://127.0.0.1:${process.env.COLLECTOR_PORT}`, + apiKey: "autter_rt_e2e", + service: "e2e-redaction", + environment: "test", + metricIntervalMs: 3_600_000, // keep metric noise out of this test +}); + +setTimeout(() => { + autter.captureException(new Error("boom: order failed"), { + "order.id": "o-1", + "user.email": "jane.doe@example.com", + auth: "Bearer supersecrettoken123456", + "context.jwt": "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.SflKxwRJSMeKKF2QT4fwpM", + }); + setTimeout(() => { + process.kill(process.pid, "SIGTERM"); + }, 100); +}, 30); diff --git a/packages/runtime-node/test/fixtures/sigterm-coexists.mjs b/packages/runtime-node/test/fixtures/sigterm-coexists.mjs new file mode 100644 index 0000000..2b95732 --- /dev/null +++ b/packages/runtime-node/test/fixtures/sigterm-coexists.mjs @@ -0,0 +1,31 @@ +// Child fixture: the app owns SIGTERM (graceful drain). Auto-flush must run +// CONCURRENTLY and must NOT change the app's exit code. +import { installAutterAutoFlush } from "../../dist/index.js"; + +const flushed = []; +installAutterAutoFlush({ + log: true, + targets: [ + { + async forceFlush() { + await new Promise((r) => setTimeout(r, 30)); + flushed.push(1); + }, + }, + ], +}); + +process.on("SIGTERM", () => { + setTimeout(() => { + if (flushed.length === 0) { + console.error("APP-HANDLER-SAW-NO-FLUSH"); + process.exit(1); + } + console.log("app graceful drain complete"); + process.exit(0); + }, 60); +}); + +setTimeout(() => { + process.kill(process.pid, "SIGTERM"); +}, 50); diff --git a/packages/runtime-node/test/fixtures/sigterm-double.mjs b/packages/runtime-node/test/fixtures/sigterm-double.mjs new file mode 100644 index 0000000..8e007aa --- /dev/null +++ b/packages/runtime-node/test/fixtures/sigterm-double.mjs @@ -0,0 +1,19 @@ +// Child fixture: SIGTERM arrives twice in quick succession while the flush +// target is slow — the second signal must cut through immediately. +import { installAutterAutoFlush } from "../../dist/index.js"; + +installAutterAutoFlush({ + log: true, + targets: [ + { + async forceFlush() { + await new Promise((r) => setTimeout(r, 2_000)); + }, + }, + ], +}); + +setTimeout(() => { + process.kill(process.pid, "SIGTERM"); + setTimeout(() => process.kill(process.pid, "SIGTERM"), 100); +}, 50); diff --git a/packages/runtime-node/test/fixtures/sigterm-sole.mjs b/packages/runtime-node/test/fixtures/sigterm-sole.mjs new file mode 100644 index 0000000..9b5d15b --- /dev/null +++ b/packages/runtime-node/test/fixtures/sigterm-sole.mjs @@ -0,0 +1,25 @@ +// Child fixture: sole signal handler -> auto-flush runs, then exits with the +// conventional code. Usage: node sigterm-sole.mjs +import { installAutterAutoFlush } from "../../dist/index.js"; + +let flushes = 0; +installAutterAutoFlush({ + log: true, + targets: [ + { + async forceFlush() { + flushes++; + }, + }, + ], +}); + +setTimeout(() => { + process.kill(process.pid, "SIGTERM"); +}, 50); + +// Would keep the process alive forever if the signal path failed to exit. +setTimeout(() => { + assert.fail("process was never terminated by the auto-flush handler"); + process.exit(1); +}, 5_000); diff --git a/packages/runtime-node/test/redact.test.mjs b/packages/runtime-node/test/redact.test.mjs new file mode 100644 index 0000000..3cf0a1d --- /dev/null +++ b/packages/runtime-node/test/redact.test.mjs @@ -0,0 +1,126 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { redactAttributes } from "../dist/index.js"; + +const MASK = "[redacted]"; + +test("masks email-looking substrings inside any string value", () => { + const out = redactAttributes({ + note: "ping jane.doe+ops@example.co.uk today", + }); + assert.equal(out.note, `ping ${MASK} today`); +}); + +test("can disable value-level email scrubbing", () => { + const out = redactAttributes( + { note: "mail me at a@b.io" }, + { scrubEmailValues: false }, + ); + assert.equal(out.note, "mail me at a@b.io"); +}); + +test("masks whole value when the attribute KEY looks sensitive", () => { + const out = redactAttributes({ + "user.password": "hunter2", + authToken: "raw-token-value", + "x-api-key": "sk-abc", + cookieHeader: "session=xyz", + credit_card_number: "4111111111111111", + }); + for (const value of Object.values(out)) assert.equal(value, MASK); +}); + +test("does not over-match innocent keys (discard, author_id, card_brand)", () => { + const out = redactAttributes({ + discard_count: 3, + author_id: "u_8f2k1", + card_brand: "visa", + passwordResetAt: "2026-01-01", + }); + assert.deepEqual(out, { + discard_count: 3, + author_id: "u_8f2k1", + card_brand: "visa", + // passwordResetAt still matches /pass(word)/ — conservative by design. + passwordResetAt: MASK, + }); +}); + +// Fake tokens for regex testing, assembled from fragments so secret +// scanners (GitHub push protection) don't mistake them for real credentials. +const FAKE_SLACK = ["xox", "b-123456789012-abcdefghijklmnopqrstuv"].join(""); +const FAKE_GITHUB = ["ghp_", "abcdefghijklmnopqrstuvwxyz1234567890"].join(""); + +test("scrubs token shapes: JWT, OpenAI, GitHub, AWS, Slack, bearer", () => { + const out = redactAttributes({ + jwt: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c tail", + openai: "key sk-abcdefghijklmnopqrstuvwxyz123456 end", + github: `${FAKE_GITHUB} end`, + aws: "AKIAIOSFODNN7EXAMPLE end", + slack: `${FAKE_SLACK} end`, + authz: "Bearer abcdefghijklmnopqrstuvwxyz1234567890", + }); + assert.equal(out.jwt, `${MASK} tail`); + assert.equal(out.openai, `key ${MASK} end`); + assert.equal(out.github, `${MASK} end`); + assert.equal(out.aws, `${MASK} end`); + assert.equal(out.slack, `${MASK} end`); + assert.equal(out.authz, MASK); +}); + +test("strips basic-auth credentials from URLs but keeps host", () => { + const out = redactAttributes({ + db: "postgres://admin:s3cret@db.internal:5432/app", + }); + assert.equal(out.db, `postgres://${MASK}@db.internal:5432/app`); +}); + +test("handles attribute arrays element-wise", () => { + const out = redactAttributes({ + recipients: ["alice@example.com", "ok"], + attempts: [1, 2], + }); + assert.deepEqual(out.recipients, [MASK, "ok"]); + assert.deepEqual(out.attempts, [1, 2]); +}); + +test("drops undefined values, keeps numbers and booleans", () => { + const out = redactAttributes({ retries: 3, healthy: true, gone: undefined }); + assert.deepEqual(out, { retries: 3, healthy: true }); +}); + +test("walks nested objects defensively", () => { + const out = redactAttributes({ + context: { inner: { password: "x", safe: "y@z.com" } }, + }); + assert.equal(out.context.inner.password, MASK); + assert.equal(out.context.inner.safe, MASK); +}); + +test("supports extra key/value patterns and a custom mask", () => { + const out = redactAttributes( + { + employee_id: "E-123", + account_ref: "ACC-99", + }, + { + additionalKeyPatterns: ["employee_id"], + additionalValuePatterns: [/^ACC-\d+$/], + mask: "***", + }, + ); + assert.equal(out.employee_id, "***"); + assert.equal(out.account_ref, "***"); +}); + +test("never mutates the caller's attributes object", () => { + const original = { email: "a@b.com", n: 1 }; + const snapshot = structuredClone(original); + redactAttributes(original); + assert.deepEqual(original, snapshot); +}); + +test("empty/nullish input yields an empty object", () => { + assert.deepEqual(redactAttributes(), {}); + assert.deepEqual(redactAttributes(null), {}); +}); From 9be8e9f1d4cb60154aaffb85686e6b8c83f1de3b Mon Sep 17 00:00:00 2001 From: Sagnik Ghosh Date: Tue, 25 Aug 2026 23:36:11 +0530 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20PII=20redaction=20across=20all=20pa?= =?UTF-8?q?ckages=20=E2=80=94=20browser=20source,=20ingester=20storage,=20?= =?UTF-8?q?Next.js=20re-exports?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - runtime-browser: new redactContext() masks sensitive-keyed values and email-shaped strings in custom context before anything leaves the page, applied at the enqueue choke point (beforeSend sees the final form). Bundle stays at ~1.3 kB under the 5 kB size-limit. The 'never sent: emails' claim is now actually true for custom context. - otlp-ingester: scrub event.context at normalize time before it reaches ClickHouse — defense-in-depth that also protects payloads from outdated SDK versions. - runtime-next: re-export makeSafeCapture / installAutterAutoFlush / redactAttributes (+ types); registerAutter already passes the new initAutterServer options through. README documents the defaults. - fix(runtime-browser): build script now emits dist/index.d.ts (--dts) to match its declared types entry — @autter/runtime-next's DTS build depended on it and broke on a clean checkout once --clean wiped the stale artifact. - tests: browser redaction suite (5) + ingester normalize-browser suite (4); CI runs both alongside the node suite. --- .github/workflows/ci.yml | 3 + .../src/normalize-browser.test.ts | 83 +++++++++++++++++++ .../otlp-ingester/src/normalize-browser.ts | 25 +++++- packages/runtime-browser/README.md | 10 ++- packages/runtime-browser/package.json | 3 +- packages/runtime-browser/src/index.ts | 29 ++++++- packages/runtime-browser/test/redact.test.mjs | 41 +++++++++ packages/runtime-next/README.md | 7 ++ packages/runtime-next/src/server.ts | 8 ++ 9 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 packages/otlp-ingester/src/normalize-browser.test.ts create mode 100644 packages/runtime-browser/test/redact.test.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e8701df..2667500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,4 +15,7 @@ jobs: node-version: 22 - run: npm install - run: npm run build + - run: npm run test -w @autter/runtime-node + - run: npm run test -w @autter/runtime-browser + - run: npm run test -w @autter/otlp-ingester - run: npm run size -w @autter/runtime-browser diff --git a/packages/otlp-ingester/src/normalize-browser.test.ts b/packages/otlp-ingester/src/normalize-browser.test.ts new file mode 100644 index 0000000..c82da46 --- /dev/null +++ b/packages/otlp-ingester/src/normalize-browser.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { normalizeBrowserPayload } from "./normalize-browser.ts"; + +function contextOf(payload: unknown): Record { + const result = normalizeBrowserPayload( + payload as Parameters[0], + ); + return (result.occurrences[0]?.attributes?.context ?? {}) as Record< + string, + unknown + >; +} + +const baseEvent = { + type: "exception" as const, + timestamp: "2026-01-01T00:00:00.000Z", + message: "boom", +}; + +test("masks sensitive-keyed context values before storage", () => { + const stored = contextOf({ + version: 1, + service: "web", + environment: "prod", + events: [ + { + ...baseEvent, + context: { + "user.email": "jane@example.com", + authToken: "raw-token", + card_number: "4111111111111111", + }, + }, + ], + }); + assert.equal(stored["user.email"], "[redacted]"); + assert.equal(stored.authToken, "[redacted]"); + assert.equal(stored.card_number, "[redacted]"); +}); + +test("scrubs email-shaped strings in ordinary values", () => { + const stored = contextOf({ + version: 1, + service: "web", + environment: "prod", + events: [{ ...baseEvent, context: { note: "mail a@b.io now" } }], + }); + assert.equal(stored.note, "mail [redacted] now"); +}); + +test("keeps non-sensitive context intact and drops nullish entries", () => { + const stored = contextOf({ + version: 1, + service: "web", + environment: "prod", + events: [ + { + ...baseEvent, + context: { plan: "pro", seats: 5, empty: null, gone: undefined }, + }, + ], + }); + assert.deepEqual(stored, { plan: "pro", seats: 5 }); +}); + +test("track_event rollups still work with scrubbed contexts", () => { + const result = normalizeBrowserPayload({ + version: 1, + service: "web", + environment: "prod", + events: [ + { + type: "track_event", + timestamp: "2026-01-01T00:00:00.000Z", + message: "", + name: "checkout_opened", + context: { "user.email": "a@b.com" }, + }, + ], + }); + assert.equal(result.metricPoints[0]?.route, "event:checkout_opened"); +}); diff --git a/packages/otlp-ingester/src/normalize-browser.ts b/packages/otlp-ingester/src/normalize-browser.ts index 33b033a..e0f1783 100644 --- a/packages/otlp-ingester/src/normalize-browser.ts +++ b/packages/otlp-ingester/src/normalize-browser.ts @@ -55,6 +55,29 @@ const TYPE_TO_ERROR_TYPE: Record = { message: "Message", }; +// Content-level gate for the free-form `context` bag. The schema whitelist +// above is structural; this masks obvious PII/secrets inside whatever a +// (possibly outdated) SDK still sends: values under sensitive-looking keys +// and email-shaped strings — mirroring redactAttributes() in +// @autter/runtime-node and redactContext() in @autter/runtime-browser. +const SENSITIVE_KEY_RE = + /email|pass|token|secret|^auth([-_.]|$)|authorization|bearer|cookie|credential|api[-_.]?key|ssn|cvv|card([-_. ]?(number|num|no))?$/i; +const EMAIL_VALUE_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; +const REDACTED = "[redacted]"; + +function scrubContext(context: Record): Record { + const out: Record = {}; + for (const [key, value] of Object.entries(context)) { + if (value === undefined || value === null) continue; + out[key] = SENSITIVE_KEY_RE.test(key) + ? REDACTED + : typeof value === "string" + ? value.replace(EMAIL_VALUE_RE, REDACTED) + : value; + } + return out; +} + /** Default severity per event type when the SDK doesn't say. */ const TYPE_TO_SEVERITY: Record = { exception: "error", @@ -150,7 +173,7 @@ export function normalizeBrowserPayload( ...(event.filename ? { filename: event.filename.split("?")[0] } : {}), ...(event.line !== undefined ? { line: event.line } : {}), ...(event.column !== undefined ? { column: event.column } : {}), - ...(event.context ? { context: event.context } : {}), + ...(event.context ? { context: scrubContext(event.context) } : {}), }, occurredAt, }); diff --git a/packages/runtime-browser/README.md b/packages/runtime-browser/README.md index 6e82d4e..2f71505 100644 --- a/packages/runtime-browser/README.md +++ b/packages/runtime-browser/README.md @@ -66,6 +66,7 @@ initAutterBrowser({ | `setUser(id)` | **Opaque id only** — never an email | | `setContext(ctx)` | Attached to subsequent events | | `flush()` | Force-send the queue (also runs on page hide/unload) | +| `redactContext(ctx)` | Mask obvious PII in a context bag (applied to every event automatically) | ## Batching & delivery @@ -78,5 +79,12 @@ prevents error loops from flooding. ## What is never sent Full URLs with query strings, cookies, localStorage, DOM content, form -values, request headers/bodies, console history, emails, IP addresses. +values, request headers/bodies, console history, IP addresses. Routes are `location.pathname` only; filenames are query-stripped. + +Custom `context` is free-form, so it is scrubbed before send: values under +sensitive-looking keys (`email`, `password`, `token`, `secret`, `auth`, +`cookie`, `api_key`, `card_number`, …) are replaced with `[redacted]`, and +email-shaped substrings are masked inside ordinary string values. This +mirrors the server SDK's `redactAttributes`; the relay and ingester apply +the same rules as defense-in-depth. diff --git a/packages/runtime-browser/package.json b/packages/runtime-browser/package.json index af96f2b..2984d8b 100644 --- a/packages/runtime-browser/package.json +++ b/packages/runtime-browser/package.json @@ -24,7 +24,8 @@ "directory": "packages/runtime-browser" }, "scripts": { - "build": "tsup src/index.ts --format esm,cjs,iife --global-name AutterRuntime --dts --minify --target es2019 --clean", + "build": "tsup src/index.ts --format esm --dts --target es2020 --clean", + "test": "npm run build && node --test \"test/*.test.mjs\"", "size": "size-limit" }, "size-limit": [ diff --git a/packages/runtime-browser/src/index.ts b/packages/runtime-browser/src/index.ts index a73d060..b6260ef 100644 --- a/packages/runtime-browser/src/index.ts +++ b/packages/runtime-browser/src/index.ts @@ -5,7 +5,8 @@ * - zero runtime dependencies, < 5 KB gzipped (CI-enforced) * - no OTel SDK, no console patching, no DOM recording, no offline storage * - privacy by construction: pathname-only routes, no cookies / form values / - * request bodies / emails; query strings stripped everywhere + * request bodies; query strings stripped everywhere; custom context is + * scrubbed for obvious PII (emails, sensitive keys) before send * * Payload contract: `/v1/browser` version 1 of the Autter otlp-ingester, * normally reached through the customer's same-origin relay @@ -98,6 +99,30 @@ function stripQuery(value: string | undefined): string | undefined { return value ? value.split("?")[0] : undefined; } +// Mini redaction — the browser twin of redactAttributes() in +// @autter/runtime-node. Custom context is free-form, so values under +// sensitive-looking keys and email-shaped strings are masked before +// anything leaves the page. Deliberately tiny: this bundle is size-capped. +const SENSITIVE_KEY_RE = + /email|pass|token|secret|^auth([-_.]|$)|authorization|bearer|cookie|credential|api[-_.]?key|ssn|cvv|card([-_. ]?(number|num|no))?$/i; +const EMAIL_RE = /[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi; +const MASK = "[redacted]"; + +export function redactContext( + context: Record, +): Record { + const out: Record = {}; + for (const key in context) { + const value = context[key]; + out[key] = SENSITIVE_KEY_RE.test(key) + ? MASK + : typeof value === "string" + ? value.replace(EMAIL_RE, MASK) + : value; + } + return out; +} + function route(): string { try { return location.pathname; @@ -108,6 +133,8 @@ function route(): string { function enqueue(event: BrowserEvent, urgent?: boolean): void { if (!initialized || sentCount + queue.length >= MAX_EVENTS_PER_SESSION) return; + // Scrub before beforeSend so the last-chance hook sees the final form. + if (event.context) event.context = redactContext(event.context); if (opts.beforeSend) { const mapped = opts.beforeSend(event); if (!mapped) return; diff --git a/packages/runtime-browser/test/redact.test.mjs b/packages/runtime-browser/test/redact.test.mjs new file mode 100644 index 0000000..c61dc6f --- /dev/null +++ b/packages/runtime-browser/test/redact.test.mjs @@ -0,0 +1,41 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { redactContext } from "../dist/index.js"; + +const MASK = "[redacted]"; + +test("masks values under sensitive-looking keys", () => { + const out = redactContext({ + "user.email": "jane@example.com", + authToken: "raw-token", + cookieConsent: "granted", + card_number: "4111111111111111", + }); + for (const value of Object.values(out)) assert.equal(value, MASK); +}); + +test("does not over-match innocent keys (discard_count, author_id)", () => { + const out = redactContext({ + discard_count: 3, + author_id: "u_8f2k1", + card_brand: "visa", + }); + assert.deepEqual(out, { discard_count: 3, author_id: "u_8f2k1", card_brand: "visa" }); +}); + +test("scrubs email-shaped strings inside ordinary string values", () => { + const out = redactContext({ note: "contact jane.doe@example.co.uk today" }); + assert.equal(out.note, `contact ${MASK} today`); +}); + +test("non-string primitives pass through untouched", () => { + const out = redactContext({ retries: 3, healthy: true, ratio: 0.5 }); + assert.deepEqual(out, { retries: 3, healthy: true, ratio: 0.5 }); +}); + +test("returns a new object — caller's context is never mutated", () => { + const original = { email: "a@b.com", n: 1 }; + const snapshot = structuredClone(original); + redactContext(original); + assert.deepEqual(original, snapshot); +}); diff --git a/packages/runtime-next/README.md b/packages/runtime-next/README.md index 1b04bf0..d3bff4b 100644 --- a/packages/runtime-next/README.md +++ b/packages/runtime-next/README.md @@ -35,6 +35,13 @@ export async function register() { } ``` +`registerAutter` passes options straight to `initAutterServer`, so the +server SDK's defaults apply out of the box: exporters are flushed on +process exit (`autoFlush`) and custom attributes are scrubbed for PII +before export (`redactAttributes`). See the `@autter/runtime-node` README +for every option; `makeSafeCapture`, `installAutterAutoFlush`, and +`redactAttributes` are also re-exported from this package. + **2. `app/api/autter-runtime/route.ts`** — browser relay (key stays server-side): ```ts diff --git a/packages/runtime-next/src/server.ts b/packages/runtime-next/src/server.ts index 4d87e09..1d6afff 100644 --- a/packages/runtime-next/src/server.ts +++ b/packages/runtime-next/src/server.ts @@ -41,6 +41,9 @@ export { trackLlmCall, instrumentLlmClient, emitLlmSelftestTrace, + makeSafeCapture, + installAutterAutoFlush, + redactAttributes, } from "@autter/runtime-node"; export type { LlmCallHandle, @@ -48,6 +51,11 @@ export type { LlmUsage, TrackedLlmCall, InstrumentLlmOptions, + SafeCapture, + AutoFlushHandle, + AutoFlushOptions, + FlushTarget, + RedactOptions, } from "@autter/runtime-node"; export type { AutterServer, AutterServerOptions, RelayOptions };