Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion docs/GETTING-STARTED.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
83 changes: 83 additions & 0 deletions packages/otlp-ingester/src/normalize-browser.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> {
const result = normalizeBrowserPayload(
payload as Parameters<typeof normalizeBrowserPayload>[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");
});
25 changes: 24 additions & 1 deletion packages/otlp-ingester/src/normalize-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,29 @@ const TYPE_TO_ERROR_TYPE: Record<string, string> = {
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, RuntimeSeverity> = {
exception: "error",
Expand Down Expand Up @@ -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,
});
Expand Down
10 changes: 9 additions & 1 deletion packages/runtime-browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
3 changes: 2 additions & 1 deletion packages/runtime-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
Expand Down
29 changes: 28 additions & 1 deletion packages/runtime-browser/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<string, unknown>,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
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;
Expand All @@ -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;
Expand Down
41 changes: 41 additions & 0 deletions packages/runtime-browser/test/redact.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
});
7 changes: 7 additions & 0 deletions packages/runtime-next/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/runtime-next/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,13 +41,21 @@ export {
trackLlmCall,
instrumentLlmClient,
emitLlmSelftestTrace,
makeSafeCapture,
installAutterAutoFlush,
redactAttributes,
} from "@autter/runtime-node";
export type {
LlmCallHandle,
LlmCallInfo,
LlmUsage,
TrackedLlmCall,
InstrumentLlmOptions,
SafeCapture,
AutoFlushHandle,
AutoFlushOptions,
FlushTarget,
RedactOptions,
} from "@autter/runtime-node";
export type { AutterServer, AutterServerOptions, RelayOptions };

Expand Down
Loading
Loading