Skip to content

docs: Vercel and Cloudflare Workers external sources - #31

Merged
sagnik11 merged 2 commits into
mainfrom
docs/vercel-cloudflare-sources
Oct 13, 2026
Merged

sagnik11 merged 2 commits into
mainfrom
docs/vercel-cloudflare-sources

Conversation

@sagnik11

@sagnik11 sagnik11 commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Adds Vercel and Cloudflare Workers to docs/EXTERNAL-SOURCES.md, matching Autter-dev/autter-monorepo#464. No SDK or ingester change.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.


View code changes stack in Autter

Summary

Summary generated by Autter.
docs: Vercel and Cloudflare Workers external sources changes 1 file (+5/-2 lines).

This change only touches documentation, metadata or generated files, so Autter summarised it from the file list instead of writing an AI description.

Changes

Docs and metadata (1 file, +5/-2)

  • docs/EXTERNAL-SOURCES.md (modified, +5/-2)

@autter-dev

autter-dev Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

✅ Safe to merge

Nothing blocks merging, and Autter found no problems.

  • 🔄 Re-run review

What we checked

✅ Code review: No problems found.


🧭 Installed and used the package like a user: take a look · from an earlier commit (c70605a)

📸 See the failing step on the Walkthrough tab

6 of 6 simulated users failed, but the same thing was already broken before this PR.

Revision: c70605a4a0e9

Package walk: @autter/runtime-browser is missing 1 file from its tarball

Package walk: require fails in a clean project

This change is a package or command-line tool, so Autter installed it the way a user receives it into a clean project and used it, rather than only running the repo's own tests.

Simulated users: 6 simulated developers using the library, compared with the code before this PR (b9292c4). Each one has a different habit: follows the docs, mistypes, double-clicks, and so on.

Goal With this PR Before this PR Result
A backend developer imports @autter/runtime-node 0/3 0/3 Already broken before this PR
A developer builds the Express-style relay handler with explicit options… 0/3 0/3 Already broken before this PR

A backend developer imports @autter/runtime-node: Already broken before this PR. Every failure also happens on the code before this PR.

  • 3 of 3 simulated users: step 0 threw: "undefined" is not valid JSON (ES module consumer, CommonJS consumer, Odd inputs; happened again on a retry; also before this PR).

A developer builds the Express-style relay handler with explicit options…: Already broken before this PR. Every failure also happens on the code before this PR.

  • 3 of 3 simulated users: step 0 threw: "undefined" is not valid JSON (ES module consumer, CommonJS consumer, Odd inputs; happened again on a retry; also before this PR).
Steps Autter took (12)
  1. check “@autter/runtime-browser: packs with npm pack” — completed
  2. check “@autter/runtime-browser: the tarball contains every file the manifest points at” — 1 missing: dist/index.cjs
  3. check “@autter/runtime-browser: installs into a clean project” — completed
  4. check “@autter/runtime-browser: import resolves” — completed
  5. check “@autter/runtime-browser: require resolves” — Node can't find /tmp/autter-package-walk-mWYy6t/consumer-2/node_modules/@autter/runtime-browser/dist/index.cjs (a file missing from the package, or a dependency that isn't declared)
  6. check “@autter/runtime-browser: a strict TypeScript consumer finds the types” — completed
  7. check “@autter/runtime-node: packs with npm pack” — completed
  8. check “@autter/runtime-node: the tarball contains every file the manifest points at” — completed
  9. check “@autter/runtime-node: installs into a clean project” — completed
  10. check “@autter/runtime-node: import resolves” — completed
  11. check “@autter/runtime-node: require resolves” — completed
  12. check “@autter/runtime-node: a strict TypeScript consumer finds the types” — completed

Live view: streamed through the Autter API relay from us-east-2.

Trying the change like a user doesn't block merging.

🧪 Test run: passed · from an earlier commit (c70605a)

Autter checked c70605a4.

86 tests ran: 85 passed, 1 failed.

  • ✅ Functional: all 68 tests passed
  • ✅ Regression: all 13 affected test files (of 44 in the repo) passed
  • ❌ Tests Autter wrote: 5 written by Autter for this change, 1 found a bug

What failed

  • ❌ truncates the message to 4000 characters (packages/otlp-ingester/src/logs.ts:134): normalizeLogs should preserve the existing ingester behavior because the PR says it makes no ingester changes, but the failing test shows the PR head does not meet that expectation.
✅ 85 passed

beforeSend failures preserve application requests and later delivery (45.402324ms) · errors carry browser, OS, route trail, and page scripts without a raw user agent (12.489351ms) · CSP blocks include a safe recent action without DOM text or URLs (8.037966ms) · XHR 5xx and network errors are captured; 4xx is ignored (17.078022ms) · masks values under sensitive-looking keys (2.091909ms) · does not over-match innocent keys (discard_count, author_id) (1.502443ms) · scrubs email-shaped strings inside ordinary string values (0.19802ms) · non-string primitives pass through untouched (1.120745ms) · returns a new object — caller's context is never mutated (0.254251ms) · SIGTERM with no other handler: flushes then exits 130/143 (471.732056ms) · second signal exits immediately instead of waiting for a slow flush (444.210623ms) · SIGTERM coexists with the app's own handler — app exit code wins (386.908971ms) · beforeExit flush completes inside the natural-exit window (340.868691ms) · e2e: captured exception attributes are redacted on the wire (460.182677ms) · e2e: uncaught exception flushes telemetry before exiting with Node's crash code (382.868926ms) · an application's existing uncaught exception handler keeps its behavior (353.275033ms) · flush reports false when a target rejects (4.572006ms) · flush reports false when a target throws synchronously (0.74814ms) · flush reports true when all targets fulfill (0.619846ms) · flush reports false when a built-in-style target propagates an exporter rejection (1.12588ms) · customer SDK operations survive the ingester and ClickHouse write boundary (229.969211ms) · operation context isolates concurrent requests and exports outcomes, steps and redacted logs (126.491789ms) · export retries retain buffered records, enforce limits and report undelivered shutdown (3832.536625ms) · masks email-looking substrings inside any string value (3.418333ms) · can disable value-level email scrubbing (0.738462ms) · masks whole value when the attribute KEY looks sensitive (0.314871ms) · does not over-match innocent keys (discard, author_id, card_brand) (1.425393ms) · scrubs token shapes: JWT, OpenAI, GitHub, AWS, Slack, bearer (0.45248ms) · strips basic-auth credentials from URLs but keeps host (0.247603ms) · handles attribute arrays element-wise (0.457ms) · drops undefined values, keeps numbers and booleans (0.308203ms) · walks nested objects defensively (0.463312ms) · supports extra key/value patterns and a custom mask (0.740451ms) · never mutates the caller's attributes object (0.299144ms) · empty/nullish input yields an empty object (0.200094ms) · redacts sensitive keys beyond the nested traversal depth (0.320967ms) · bounds extremely deep object traversal safely (1.494822ms) · keeps only supported GenAI/usage token-count attributes (0.437771ms) · masks invalid values for supported GenAI usage keys (0.212312ms) · still masks secret token keys ending in 'token' (0.287067ms) · …and 32 more

Raw output from the failing commands:

Show details
…
.test.ts:135:10)
        Test.runInAsyncScope (node:async_hooks:227:14)
        Test.run (node:internal/test_runner/test:1402:25)
        Suite.processPendingSubtests (node:internal/test_runner/test:974:18)
        Test.postRun (node:internal/test_runner/test:1542:19)
        Test.run (node:internal/test_runner/test:1467:12)
        async Suite.processPendingSubtests (node:internal/test_runner/test:974:7)
      ...
    # Subtest: produces the same id for the same record
    ok 13 - produces the same id for the same record
      ---
      duration_ms: 0.488278
      type: 'test'
      ...
    # Subtest: new: takes service from the service.name resource attribute
    ok 14 - new: takes service from the service.name resource attribute
      ---
      duration_ms: 0.181776
      type: 'test'
      ...
    # Subtest: new: defaults service to unknown when service.name is absent
    ok 15 - new: defaults service to unknown when service.name is absent
      ---
      duration_ms: 0.177497
      type: 'test'
      ...
    # Subtest: new: prefers deployment.environment.name over deployment.environment
    ok 16 - new: prefers deployment.environment.name over deployment.environment
      ---
      duration_ms: 0.325544
      type: 'test'
      ...
    # Subtest: new: falls back to deployment.environment then production
    ok 17 - new: falls back to deployment.environment then production
      ---
      duration_ms: 0.212105
      type: 'test'
      ...
    # Subtest: new: takes release from service.version and defaults to empty
    ok 18 - new: takes release from service.version and defaults to empty
      ---
      duration_ms: 0.227748
      type: 'test'
      ...
    # Subtest: new: classifies autter.event.type operation as an operation row
    ok 19 - new: classifies autter.event.type operation as an operation row
      ---
      duration_ms: 0.265174
      type: 'test'
      ...
    # Subtest: new: sets occurredAt from timeUnixNano in whole milliseconds
    ok 20 - new: sets occurredAt from timeUnixNano in whole milliseconds
      ---
      duration_ms: 0.157512
      type: 'test'
      ...
    # Subtest: new: falls back to observedTimeUnixNano when timeUnixNano is absent
    ok 21 - new: falls back to observedTimeUnixNano when timeUnixNano is absent
      ---
      duration_ms: 0.168809
      type: 'test'
      ...
    # Subtest: new: strips query and fragment from attributes.path using normalizeRoute
    ok 22 - new: strips query and fragment from attributes.path using normalizeRoute
      ---
      duration_ms: 0.554156
      type: 'test'
      ...
    1..22
not ok 1 - normalizeLogs
  ---
  duration_ms: 193.746083
  type: 'suite'
  location: '/tmp/autter-agentic-zFu3Ko/repo/packages/otlp-ingester/src/logs.autter.test.ts:1:764'
  failureType: 'subtestsFailed'
  error: '1 subtest failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 22
# suites 1
# pass 21
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 413.59676

Customer command exited 1
Full test report

This PR changes 4 source files. It ran the project's test commands for the workspaces this PR touched, because those suites are the fastest check that the change did not break existing behavior. It also executed 8 checks from the PR's test plan.

Targeted agent checks: Autter ran targeted checks against the files this PR changed — it executed uncovered test-plan items and, where a changed file had no matching test, wrote a temporary test to verify the new behavior.

Execution summary: 13 checks executed · 11 passed · 2 failed.

Project test commands

Scope Command Result
@autter/runtime-browser npm run test ✅ passed · 3s
@autter/runtime-node npm run test ✅ passed · 9s

Autter targeted verification

8 tests executed · 8 passed.

Critical feature verification

Could not run: The check ran longer than its time limit.

Code-level simulations

Revision c70605a4a0e9 · 456.6s · up to 2 scenarios in parallel

  • Relay never forwards credential-shaped context or event text (per [L:pr-risk-de62f2219e2fb0a96b16eb48c1b2c707], [L:pr-risk-e05ca273f107abf49fdd431c6643c55b]) — failed (code_diff, code_diff, spec)
    Source: packages/runtime-node/src/relay.ts — message: typeof e.message === "string" ? e.message.slice(0, 4000) : "",
    Source: packages/runtime-node/src/relay.ts — ? { context: boundContext(e.context) }
    Source: docs/EXTERNAL-SOURCES.md — Do not forward secrets, cookies, request bodies or personal data unnecessarily.
    Ran: node '.autter/scratch/relay-credential-boundary.mjs'
    ✅ The relay responds 202 and forwards only whitelisted payload fields with the server API key in the Authorization header.: expected HTTP 202; POST /v1/browser; server Authorization header is correct; payload fields are whitelisted; observed {"status202":true,"forwardTargetCorrect":true,"serverAuthorizationCorrect":true,"onlyWhitelistedFields":true} (integration)
    ❌ No raw credentials or email appear in the forwarded context, message, or stack; ordinary non-sensitive context remains available.: expected Sensitive test markers are absent from forwarded context/message/stack; ordinary feature context is preserved; observed {"rawSensitiveValuesAbsent":false,"ordinaryContextPreserved":true} (integration)
    Diagnosis could not complete: The check ended without a usable result.
    Regression: Simulation did not emit exactly one completed assertion report.
    Indexed blast radius: 0 direct / 0 transitive dependents (indexed 2026-10-11T19:25:15.616Z).
  • Relay redacts numeric values under sensitive keys but retains usage counts (per [L:pr-risk-f85d888ced0c7baa63d1cd78ea3292c6]) — failed (code_diff, code_diff)
    Source: packages/runtime-node/src/relay.ts — out[key] = rt === "number" || rt === "boolean" ? raw : REDACTED;
    Source: packages/runtime-node/src/relay.ts — const SECRET_KEY_RE =
    /(password|passwd|pwd|passphrase|passcode|secret|token|api[-]?key|apikey|access[-]?key|secret[-]?key|private[-]?key|authorization|cookie|session[-]?id|sessionid|session|credentials?|bearer|jwt|otp|x-api-key|signature)/i;
    Ran: node '.autter/scratch/relay-numeric-sensitive-key-harness.mjs'
    ❌ The OTP and session identifier are redacted or absent, never preserved as raw values.: expected otp and sessionId are redacted or absent; observed otp: preserved raw value, sessionId: preserved raw value (code)
    ✅ The validated usage-count values remain unchanged.: expected {"input_tokens":500,"output_tokens":1200,"total_tokens":1700}; observed {"input_tokens":500,"output_tokens":1200,"total_tokens":1700} (code)
    Root cause (confirmed): Confirmed changed-code defect: in packages/runtime-node/src/relay.ts, boundContext recognizes otp and sessionId as sensitive keys, but the matching-key branch preserves every number or boolean unchanged (lines 177-190). Therefore {otp: 123456, sessionId: 987654} survives sanitizeBrowserPayload as raw values. The same broad type exception is why input_tokens/output_tokens/total_tokens are retained; it has no explicit usage-counter allowlist. This is the numeric-credential failure described in [L:pr-risk-f85d888ced0c7baa63d1cd78ea3292c6].
    packages/runtime-browser/src/index.ts:124 — The browser SDK accepts caller context through capture APIs, global context and trackEvent props (lines 415, 453, 462-463). Its client-side key regex at lines 124-125 does not include otp or sessionId, so enqueue's redaction at lines 310-312 does not remove those keys before posting. This is an upstream gap, but the relay is the decisive broken boundary.
    packages/runtime-node/src/relay.ts:101 — SECRET_KEY_RE explicitly matches otp and session[
    -]?id/sessionid. In boundContext, the sensitive-key branch then copies any number or boolean unchanged at lines 177-190; this contradicts the comment's claim that such values cannot be credentials.
    packages/runtime-node/src/relay.ts:300 — sanitizeBrowserPayload applies boundContext to each event context, so the raw numeric values appear in the sanitized event returned by this function, matching the harness observation.

Cut to fit in one comment. See everything in Autter.

📌 2 findings outside the changed lines
oxlint: unicorn(no-useless-fallback-in-spread) (packages/runtime-browser/src/index.ts:267)

🟡 [deterministic] oxlint: unicorn(no-useless-fallback-in-spread) — Risk: 30/100

packages/runtime-browser/src/index.ts:267:21: Empty fallbacks in spreads are unnecessary

🛠 AI fix prompt (copy & paste into your coding agent)
Fix the oxlint `unicorn(no-useless-fallback-in-spread)` issue at packages/runtime-browser/src/index.ts:267: packages/runtime-browser/src/index.ts:267:21: Empty fallbacks in spreads are unnecessary

Flagged by Autter security & observability checks.

Autter-written test fails: truncates the message to 4000 characters (packages/otlp-ingester/src/logs.ts:134)

🟠 [verified] Autter-written test fails: truncates the message to 4000 characters — Risk: 65/100

normalizeLogs should preserve the existing ingester behavior because the PR says it makes no ingester changes, but the failing test shows the PR head does not meet that expectation.

Autter wrote packages/otlp-ingester/src/logs.autter.test.ts for the new behaviour this PR describes. It fails on this PR (twice, so it is not flaky).

 from service.version and defaults to empty
    ok 18 - new: takes release from service.version and defaults to empty
      ---
      duration_ms: 0.227748
      type: 'test'
      ...
    # Subtest: new: classifies autter.event.type operation as an operation row
    ok 19 - new: classifies autter.event.type operation as an operation row
      ---
      duration_ms: 0.265174
      type: 'test'
      ...
    # Subtest: new: sets occurredAt from timeUnixNano in whole milliseconds
    ok 20 - new: sets occurredAt from timeUnixNano in whole milliseconds
      ---
      duration_ms: 0.157512
      type: 'test'
      ...
    # Subtest: new: falls back to observedTimeUnixNano when timeUnixNano is absent
    ok 21 - new: falls back to observedTimeUnixNano when timeUnixNano is absent
      ---
      duration_ms: 0.168809
      type: 'test'
      ...
    # Subtest: new: strips query and fragment from attributes.path using normalizeRoute
    ok 22 - new: strips query and fragment from attributes.path using normalizeRoute
      ---
      duration_ms: 0.554156
      type: 'test'
      ...
    1..22
not ok 1 - normalizeLogs
  ---
  duration_ms: 193.746083
  type: 'suite'
  location: '/tmp/autter-agentic-zFu3Ko/repo/packages/otlp-ingester/src/logs.autter.test.ts:1:764'
  failureType: 'subtestsFailed'
  error: '1 subtest failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 22
# suites 1
# pass 21
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 413.59676

Customer command exited 1
🛠 AI fix prompt (copy & paste into your coding agent)
normalizeLogs should preserve the existing ingester behavior because the PR says it makes no ingester changes, but the failing test shows the PR head does not meet that expectation. Reproduce with the test below (save it as packages/otlp-ingester/src/logs.autter.test.ts) and fix the code at packages/otlp-ingester/src/logs.ts:134, not the test:

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { normalizeRoute } from "./fingerprint.js";
import { normalizeLogs, type OtlpLogsRequest } from "./logs.js";

const NANOS_2026_01_01 = "1767225600000000000"; // 2026-01-01T00:00:00.000Z

function attr(key: string, stringValue: string) {
	return { key, value: { stringValue } };
}

function record(overrides: Record<string, unknown> = {}) {
	return {
		timeUnixNano: NANOS_2026_01_01,
		severityNumber: 9,
		body: { stringValue: "hello" },
		...overrides,
	};
}

function single(
	logRecord: Record<string, unknown> = record(),
	resourceAttributes: Array<{ key: string; value: { stringValue: string } }> = [
		attr("service.name", "checkout"),
	],
): OtlpLogsRequest {
	return {
		resourceLogs: [
			{
				resource: { attributes: resourceAttributes },
				scopeLogs: [{ logRecords: [logRecord] }],
			},
		],
	} as OtlpLogsRequest;
}

describe("normalizeLogs", () => {
	it("rejects a request whose resourceLogs is not an array", () => {
		assert.throws(
			() => normalizeLogs({} as OtlpLogsRequest),
			/resourceLogs must be an array/,
		);
		assert.throws(
			() => normalizeLogs(undefined as unknown as OtlpLogsRequest),
			/resourceLogs must be an array/,
		);
	});

	it("returns no rows for an empty resourceLogs array", () => {
		assert.deepEqual(normalizeLogs({ resourceLogs: [] }), []);
	});

	it("rejects more than 128 log resources", () => {
		const resourceLogs = Array.from({ length: 129 }, () => ({
			scopeLogs: [],
		}));
		assert.throws(
			() => normalizeLogs({ resourceLogs }),
			/too many log resources/,
		);
	});

	it("rejects a resource with more than 128 scopes", () => {
		const scopeLogs = Array.from({ length: 129 }, () => ({ logRecords: [] }));
		assert.throws(
			() => normalizeLogs({ resourceLogs: [{ scopeLogs }] }),
			/invalid log scopes/,
		);
	});

	it("rejects a null log record", () => {
		assert.throws(
			() =>
				normalizeLogs({
					resourceLogs: [{ scopeLogs: [{ logRecords: [null as never] }] }],
				}),
			/invalid log record/,
		);
	});

	it("rejects more than 2000 log records in one request", () => {
		const logRecords = Array.from({ length: 2001 }, () => record());
		assert.throws(
			() =>
				normalizeLogs({
					resourceLogs: [{ scopeLogs: [{ logRecords }] }],
				}),
			/too many log records/,
		);
	});

	it("accepts exactly 2000 log records", () => {
		const logRecords = Array.from({ length: 2000 }, () => record());
		const rows = normalizeLogs({
			resourceLogs: [{ scopeLogs: [{ logRecords }] }],
		});
		assert.equal(rows.length, 2000);
	});

	it("takes the message from the body string", () => {
		const [row] = normalizeLogs(single(record({ body: { stringValue: "boom" } })));
		assert.equal(row?.message, "boom");
	});

	it("maps severity numbers and text to severity buckets", () => {
		const severityOf = (severityNumber: number, severityText?: string) =>
			normalizeLogs(single(record({ severityNumber, severityText })))[0]?.severity;
		assert.equal(severityOf(21), "fatal");
		assert.equal(severityOf(17), "error");
		assert.equal(severityOf(13), "warning");
		assert.equal(severityOf(5), "debug");
		assert.equal(severityOf(9), "info");
		assert.equal(severityOf(9, "fatal"), "fatal");
		assert.equal(severityOf(9, "WARN"), "warning");
	});

	it("keeps a valid trace id lowercased and blanks an all-zero trace id", () => {
		const upper = "ABCDEF0123456789ABCDEF0123456789";
		const [kept] = normalizeLogs(single(record({ traceId: upper })));
		assert.equal(kept?.traceId, upper.toLowerCase());

		const [blank] = normalizeLogs(single(record({ traceId: "0".repeat(32) })));
		assert.equal(blank?.traceId, "");
	});

	it("rejects malformed trace ids by leaving them empty", () => {
		const [row] = normalizeLogs(single(record({ traceId: "not-hex" })));
		assert.equal(row?.traceId, "");
	});

	it("truncates the message to 4000 characters", () => {
		const [row] = normalizeLogs(
			single(record({ body: { stringValue: "x".repeat(5000) } })),
		);
		assert.equal(row?.message.length, 4000);
	});

	it("produces the same id for the same record", () => {
		const a = normalizeLogs(single())[0];
		const b = normalizeLogs(single())[0];
		assert.ok(a);
		assert.equal(a.id, b?.id);
		assert.match(a.id, /^[0-9a-f]{32}$/);
	});

	it("new: takes service from the service.name resource attribute", () => {
		const [row] = normalizeLogs(single());
		assert.equal(row?.service, "checkout");
	});

	it("new: defaults service to unknown when service.name is absent", () => {
		const [row] = normalizeLogs(single(record(), []));
		assert.equal(row?.service, "unknown");
	});

	it("new: prefers deployment.environment.name over deployment.environment", () => {
		const [row] = normalizeLogs(
			single(record(), [
				attr("deployment.environment.name", "staging"),
				attr("deployment.environment", "dev"),
			]),
		);
		assert.equal(row?.environment, "staging");
	});

	it("new: falls back to deployment.environment then production", () => {
		const [legacy] = normalizeLogs(
			single(record(), [attr("deployment.environment", "dev")]),
		);
		assert.equal(legacy?.environment, "dev");

		const [fallback] = normalizeLogs(single(record(), []));
		assert.equal(fallback?.environment, "production");
	});

	it("new: takes release from service.version and defaults to empty", () => {
		const [withVersion] = normalizeLogs(
			single(record(), [attr("service.version", "1.2.3")]),
		);
		assert.equal(withVersion?.release, "1.2.3");

		const [without] = normalizeLogs(single(record(), []));
		assert.equal(without?.release, "");
	});

	it("new: classifies autter.event.type operation as an operation row", () => {
		const [operation] = normalizeLogs(
			single(record({ attributes: [attr("autter.event.type", "operation")] })),
		);
		assert.equal(operation?.type, "operation");

		const [log] = normalizeLogs(single());
		assert.equal(log?.type, "log");
	});

	it("new: sets occurredAt from timeUnixNano in whole milliseconds", () => {
		const [ro

Flagged by Autter security & observability checks.

💡 More findings (10): suggestions and ones Autter couldn't confirm

🧭 PR hygiene & process suggestions

Autter has 6 suggestion(s) about the shape of this PR (size, scope, reviewability). These are process guidance — not code defects — so they are consolidated here instead of posted as inline comments on individual files.

🟠 PR changes too many lines — split it — Risk: 75/100

This PR changes 567 lines (limit 500 lowered from 1000 due to high-impact files). Largest contributors: packages/runtime-browser/src/index.ts (13 lines), packages/runtime-node/test/relay.test.mjs (270 lines), packages/runtime-node/src/relay.ts (219 lines). Smaller, focused PRs are reviewed more reliably. Blast radius — if a regression hides in this large diff it cascades: functions attachClient, clientEnv, AutterBrowserOptions, byteLength, boundContext, sanitizeBrowserPayload, createBrowserRelayFetchHandler, createBrowserRelayHandler; scopes @autter/runtime-browser; dependent files packages/runtime-browser/test/before-send.test.mjs, packages/runtime-node/test/relay.test.mjs, packages/runtime-node/src/index.ts, packages/runtime-next/src/server.ts, packages/runtime-next/src/client.ts, packages/runtime-browser/test/client-context.test.mjs, packages/runtime-browser/test/coded-errors.test.mjs, packages/runtime-node/test/relay-compat.test.mjs.

References:

🛠 AI fix prompt (copy & paste into your coding agent)
Reduce the diff size of this PR. Move unrelated changes to follow-up PRs and prefer a chain of small, sequential PRs over one large one.
Start with `packages/runtime-browser/src/index.ts` — see if it can land as its own PR or be split along clear boundaries (e.g. one PR per public API change).
Target: total changed lines well under 1000.

References:
https://google.github.io/eng-practices/review/developer/small-cls.html

🟠 Change not tied to any stated requirement — Risk: 70/100

Flagged in 3 places: packages/runtime-node/src/relay.ts:122, packages/runtime-browser/src/index.ts:35, packages/runtime-node/test/relay.test.mjs:3.

No stated requirement asked for this: Relay hardening: boundContext depth, node, string and key caps, plus secret-key and JWT redaction, applied to browser-supplied context. Confirm it belongs in this PR rather than a separate change.

🛠 AI fix prompt (copy & paste into your coding agent)
Confirm this change is in scope for the PR's stated intent. If it is unrelated, split it into its own PR; if it is required, note why in the PR description.

🟠 PR mixes refactor and behavior change — Risk: 55/100

A docs-only change is combined with SDK and relay behavior changes in browser and Node runtimes, including a public option and rate-limit semantics that alter existing deployments. docs/EXTERNAL-SOURCES.md and packages/runtime-node/src/relay.ts are the files contributing most to the mix. Keep the EXTERNAL-SOURCES.md docs change alone. Move the relay hardening (trustProxy, boundContext, readBodyBounded) and the beforeSend isolation into separate SDK PRs, each with its own changelog and semver note. relay.ts is a high-risk file given its shared-bucket behavior change. Blast radius — if the refactor introduces a regression the behavior change masks it on: dependent files packages/runtime-browser/test/before-send.test.mjs, packages/runtime-node/test/relay.test.mjs, packages/runtime-node/src/index.ts, packages/runtime-next/src/server.ts, packages/runtime-next/src/client.ts, packages/runtime-browser/test/client-context.test.mjs, packages/runtime-browser/test/coded-errors.test.mjs, packages/runtime-node/test/relay-compat.test.mjs.

References:

🛠 AI fix prompt (copy & paste into your coding agent)
Split this PR so refactors land separately from behavior changes.
Pure-refactor PRs should preserve behavior (no test changes beyond renames). Behavior-change PRs should focus on a single new capability.
Start by extracting `docs/EXTERNAL-SOURCES.md`'s refactor portion (or its behavior portion, whichever is smaller) into its own PR.

References:
https://trunkbaseddevelopment.com/
https://martinfowler.com/articles/branching-patterns.html

🟡 Requirement not implemented — Risk: 48/100

The PR description asked for: "Bound and sanitise relay context, body size and rate-limit keying", but the diff does not deliver it. Not requested by the title or description. The relay hardening is unrelated to the Vercel and Cloudflare docs.

🛠 AI fix prompt (copy & paste into your coding agent)
Implement "Bound and sanitise relay context, body size and rate-limit keying" as the PR description asked, or confirm it is intentionally deferred and record that in the PR description.

Flagged by Autter PR-hygiene checks.


💡 2 minor suggestions: conventions, hardening and tidiness (not bugs)

Comments on the code are kept for real bugs. These are things worth knowing that the change doesn't prove wrong: a missing safety layer, a convention the repo usually follows, a file that usually changes together with one you touched. Take what fits and ignore the rest. They don't block merging.

  • 🟠 Validate beforeSend results before queueing (risk 69/100) — packages/runtime-browser/src/index.ts:317
  • 🟠 Comment's 'numbers can never be credentials' claim is false; numeric OTPs/session IDs are forwarded raw (risk 50/100) — packages/runtime-node/src/relay.ts:98
❔ 2 possible problems Autter couldn't confirm

Autter's checks flagged these, but a second look couldn't confirm them from the code it could see, so they are listed here instead of on the code. Check the ones that look real to you. They don't block merging.

  • 🟡 High complexity in context walker (cyclomatic complexity ~24) (risk 25/100) — packages/runtime-node/src/relay.ts:125 — The walker is dense with nested try/catch and branches, so the complexity is real, but this is a maintainability concern rather than a defect. Nothing in the provided code shows a bug caused by it.
  • 🟡 Generic placeholder identifier in production logic (risk 10/100) — packages/runtime-node/src/relay.ts:132 — obj is a short local alias inside a small walker. The name is generic, but this is a style nit with no behavioural impact.

🔎 1 low-signal finding(s) (test-file conventions, hardening advice on internal routes, packages the registry knows) were kept off this PR — see the Autter dashboard.

🚦 Pre-merge checks: 11 passed
✅ 11 passed

API key in source · Access token in source · Private key in source · Database connection URL with embedded credentials · Cloud credential in source · Webhook signing secret in source · OAuth client secret in source · JWT signing secret in source · Hardcoded password · Scanner suppressions added · Scanner settings changed

☑️ Task list (2)
  • Redact sensitive values under benign context keys (packages/runtime-node/src/relay.ts, packages/runtime-node/test/relay.test.mjs) - Harden boundContext and sanitizeBrowserPayload so credential-shaped strings and PII are redacted even when stored under innocuous keys such as note, and add relay tests proving neither value reaches the ingester.
  • Prevent spoofed forwarded headers from changing rate-limit identity (packages/runtime-node/src/relay.ts, packages/runtime-node/test/relay.test.mjs) - Ensure firstForwardedFor uses forwarded addresses only when the immediate peer is a configured trusted proxy, and test that an untrusted client cannot evade its rate-limit bucket by spoofing X-Forwarded-For.

Generated from PR diff, blast radius, and context.

Issues found

No unresolved code findings remain for this revision — 11 finding(s) dismissed as likely false positives by verification. Dismissed findings stay listed with their verdicts in the Autter review dashboard.

23 additional non-blocking finding(s) are available in the Autter review.

🤖 Release Notes Curator

Release impact: minor. This adds Vercel and Cloudflare Workers as supported external log sources, plus failed-build CI/CD incidents for both, which is a new user-facing capability. The PR is docs-only, but the features it documents are user-visible.

Label note: I applied release: patch before finishing the analysis, and the PR already carried release: minor. Please remove release: patch so only release: minor remains. I don't have a tool to remove labels.

Changelog entry (draft):

New: Vercel and Cloudflare Workers log sources. You can now connect Vercel and Cloudflare Workers to Autter Runtime alongside Sentry, PostHog, Grafana/Loki, and Datadog. Runtime pulls in your application logs and surfaces errors with their context, so problems in deployed apps show up in one place. Failed Vercel deployments and failed Cloudflare Workers Builds on your default branch are also reported as CI/CD incidents, including the build log.

Custom agent · runs after review · configured in Autter

📄 What this PR changes
  • Review docs changes
📖 What these words mean
  • Must fix: Blocks merging. Autter is confident this is a real problem in this PR.
  • Should fix: Doesn't block merging, but is likely a real problem worth fixing.
  • Minor: Style, conventions or hardening ideas. Fine to skip.
  • Simulated users: Autter runs your app, package or CLI the way people would, each with a different habit (follows the docs, mistypes a command, …).
  • Already broken before this PR: The same failure happens on the code before this PR, so this PR didn't cause it.
  • We couldn't tell: Some runs failed, but not clearly enough to blame this PR.
  • Doesn't block merging: Shown for your information; the merge check ignores it.

View this PR in Autter · Checked commit 2dfd1a7 · Tick Re-run review to check this commit again

@autter-dev autter-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autter left 3 comments on the code below (🟠 2 · 🟡 1). The autter/review-gate check says whether anything blocks merging. Each comment has a prompt you can paste into your coding agent.

Comment thread packages/runtime-node/src/relay.ts Outdated

/** Redact a string that looks like a credential (auth header value / JWT). */
function scrubSecretValue(s: string): string {
return SECRET_VALUE_RE.test(s) || JWT_RE.test(s) ? REDACTED : s;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [ai] Opaque credentials survive under benign context keys — Risk: 75/100

This only redacts Bearer/Basic values and JWTs, so opaque API keys or other credential-shaped strings under ordinary keys such as note pass through into privileged telemetry. The relay’s stated secret-scrubbing intent is therefore not met. Blast radius — if this issue ships it degrades the downstream usage that depends on this file: functions byteLength, boundContext, forward, sanitizeBrowserPayload, relayContext, respond, createBrowserRelayFetchHandler, createBrowserRelayHandler; scopes @autter/runtime-node; dependent files packages/runtime-browser/test/before-send.test.mjs, packages/runtime-node/test/relay.test.mjs, packages/runtime-node/src/index.ts, packages/runtime-next/src/server.ts, packages/runtime-next/src/client.ts, packages/runtime-browser/test/client-context.test.mjs, packages/runtime-browser/test/coded-errors.test.mjs, packages/runtime-node/test/relay-compat.test.mjs.

Flagged by the Release Notes Curator agent.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Functions/symbols: byteLength, boundContext, forward, sanitizeBrowserPayload, relayContext, respond, createBrowserRelayFetchHandler, createBrowserRelayHandler
  • Dependent files: packages/runtime-browser/test/before-send.test.mjs, packages/runtime-node/test/relay.test.mjs, packages/runtime-node/src/index.ts, packages/runtime-next/src/server.ts, packages/runtime-next/src/client.ts, packages/runtime-browser/test/client-context.test.mjs, packages/runtime-browser/test/coded-errors.test.mjs, packages/runtime-node/test/relay-compat.test.mjs
  • Scopes: @autter/runtime-node
🛠 AI fix prompt (copy & paste into your coding agent)
Extend context value scrubbing to detect and redact common opaque credential formats, including provider-style API keys, even when stored under benign keys. Add tests showing representative credential-shaped strings are redacted while ordinary text remains unchanged.

Flagged by Autter security & observability checks.

Comment thread packages/runtime-node/src/relay.ts Outdated
* Only a strict boolean `true` enables it — a truthy string such as the
* common `process.env.TRUST_PROXY === "false"` slip stays on the safe path.
*/
trustProxy?: boolean;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [ai] Change contradicts stated intent — Risk: 58/100

The PR description asked for: "No SDK or ingester change (docs-only PR)", but this change does the opposite or breaks it. The diff changes packages/runtime-browser/src/index.ts (beforeSend isolation and a new doc string), packages/runtime-node/src/relay.ts (new trustProxy option, boundContext, readBodyBounded, rate-limit keying, context sanitising) and adds two test files. None of this is docs-only.

🛠 AI fix prompt (copy & paste into your coding agent)
Reconcile this change with "No SDK or ingester change (docs-only PR)". Either align the implementation with the stated intent or update the intent if it is out of date.

Flagged by Autter security & observability checks.

} catch {
child = undefined;
}
if (child !== undefined) out[key] = child;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [ai] __proto__ context key triggers the prototype setter instead of being copied — Risk: 20/100

In boundContext, a browser-supplied __proto__ key (an own property after JSON.parse) is assigned with out[key] = ... on a plain object literal. This invokes the __proto__ setter instead of creating an own property. An object value becomes the local copy's prototype and is dropped on serialization; a primitive value is ignored. Global prototypes are not polluted and the impact is limited to losing that key. Use Object.create(null) / Object.defineProperty, or skip __proto__ explicitly, for deterministic behavior.

⚠ Downstream affected — if this fails, it cascades to the usage that depends on this file:

  • Functions/symbols: byteLength, boundContext, forward, sanitizeBrowserPayload, relayContext, respond, createBrowserRelayFetchHandler, createBrowserRelayHandler
  • Dependent files: packages/runtime-browser/test/before-send.test.mjs, packages/runtime-node/test/relay.test.mjs, packages/runtime-node/src/index.ts, packages/runtime-next/src/server.ts, packages/runtime-next/src/client.ts, packages/runtime-browser/test/client-context.test.mjs, packages/runtime-browser/test/coded-errors.test.mjs, packages/runtime-node/test/relay-compat.test.mjs
  • Scopes: @autter/runtime-node
🛠 AI fix prompt (copy & paste into your coding agent)
Avoid prototype-setter behavior while building the sanitized context: create the output with a null prototype or define properties explicitly as own data properties. Add a relay test using a context with an own `__proto__` key and verify sanitization and serialization preserve it without changing the output prototype. Blast radius — if this defect reaches production it can fail the downstream usage that depends on this file: functions `byteLength`, `boundContext`, `forward`, `sanitizeBrowserPayload`, `relayContext`, `respond`, `createBrowserRelayFetchHandler`, `createBrowserRelayHandler`; scopes `@autter/runtime-node`; dependent files `packages/runtime-browser/test/before-send.test.mjs`, `packages/runtime-node/test/relay.test.mjs`, `packages/runtime-node/src/index.ts`, `packages/runtime-next/src/server.ts`, `packages/runtime-next/src/client.ts`, `packages/runtime-browser/test/client-context.test.mjs`, `packages/runtime-browser/test/coded-errors.test.mjs`, `packages/runtime-node/test/relay-compat.test.mjs`.

Flagged by Autter security & observability checks.

@autter-dev autter-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autter left 1 comment on the code below (🟡 1). The autter/review-gate check says whether anything blocks merging. Each comment has a prompt you can paste into your coding agent.

Comment thread docs/EXTERNAL-SOURCES.md
# Connect external logs to Runtime

The Autter platform can ingest repository-scoped logs from Sentry, PostHog, Grafana/Loki, Datadog and generic webhooks. Configure them under **Repository → Settings → Runtime → Data sources** when this feature is enabled on your deployment.
The Autter platform can ingest repository-scoped logs from Sentry, PostHog, Grafana/Loki, Datadog, Vercel, Cloudflare Workers and generic webhooks. Configure them under **Repository → Settings → Runtime → Data sources** when this feature is enabled on your deployment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 [deterministic] markdownlint: MD013 — Risk: 30/100

Line length: Expected: 80; Actual: 268

🛠 AI fix prompt (copy & paste into your coding agent)
Fix the markdownlint `MD013` issue at docs/EXTERNAL-SOURCES.md:3: Line length: Expected: 80; Actual: 268

Flagged by Autter security & observability checks.

@autter-dev autter-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autter left 1 comment on the code below (🟠 1). The autter/review-gate check says whether anything blocks merging. Each comment has a prompt you can paste into your coding agent.

Comment thread packages/runtime-node/src/relay.ts Outdated
if (limiter) {
// Only honor X-Forwarded-For behind an explicitly trusted proxy —
// otherwise a caller could spoof a fresh IP per request to bypass
// the window. With no trusted peer source in a fetch runtime, fall

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [ai] Unauthenticated clients can exhaust a shared relay rate-limit bucket and block all browser telemetry ingestion in the fetch handler — Risk: 55/100

The Fetch-style browser relay ignores X-Forwarded-For by default and keys every request into one global rate-limit bucket. Any anonymous client that sends 120 requests in a minute therefore causes the relay to reject all other browsers' telemetry for the rest of that window.

The fetch handler selects the limiter key as follows:

 if (limiter) {
 const ip =
 opts.trustProxy === true
 ? firstForwardedFor(request.headers.get("x-forwarded-for")) ||
 "unknown"
 : "shared";
 if (!limiter.allow(ip)) {

When trustProxy is not true (the default), the key is the constant string "shared", so the IpWindow counts every caller against one budget. The change from the previous code (which keyed on the spoofable XFF header, so a spoofing attacker could already evade the window) removes per-client isolation entirely: a single attacker now consumes the budget for every browser. The limiter runs before body parsing and before the 202 response, so rejected legitimate traffic is dropped rather than queued.

The Node handler keys on req.socket.remoteAddress when trustProxy is off, which isolates clients unless a reverse proxy sits in front of it. This finding is specific to the fetch handler, which has no peer address available.

  1. Deploy createBrowserRelayFetchHandler({ apiKey, perIpRateLimit: 120 }) (defaults, trustProxy unset) on a route.
  2. From an attacker client, send 120 valid POST requests in one minute, rotating the X-Forwarded-For value on each request to show that spoofing is irrelevant.
  3. Observe that all 120 return 202 and the next request returns 429.
  4. From a different client address (a legitimate end user with a valid payload), send one POST within the same minute. The response is 429, so that user's telemetry is dropped.

Evidence:
Output of the reproduction script in this run (relay dist built from the reviewed source, Node 24):

attacker statuses: { '202': 120, '429': 10 }
legitimate user status: 429

The in-repo test relay: spoofed X-Forwarded-For cannot bypass the rate limit by default asserts the same shared-bucket behavior, so the behavior is intentional. It is reported as an availability trade-off, not a regression in the test suite.

The shared bucket is deliberate. The code comment says it is a conservative global limit, and the test suite asserts it. The impact is telemetry only: the customer's application still serves requests and no data is exposed or modified. A deployment behind a WAF or edge rate limiter would reduce exposure. Deployments that enable trustProxy behind a proxy that overwrites XFF do not hit this path. The Node handler is not affected by the shared bucket because it keys on the socket address. Attack traffic is visible to the operator and easy to stop by disabling the route. I checked for a documented or enforced exemption and found none in relay.ts or the docs.

Proof of concept:

import { createBrowserRelayFetchHandler } from "./dist/index.js";

const handler = createBrowserRelayFetchHandler({
 apiKey: "autter_rt_test",
 endpoint: "http://127.0.0.1:9",
 onError: () =&gt; {},
});
const body = JSON.stringify({
 version: 1, service: "web", environment: "production",
 events: [{ type: "exception", timestamp: new Date().toISOString(), message: "legit error" }],
});
const mk = (ip) =&gt; new Request("http://app.example.test/api/autter-runtime", {
 method: "POST",
 headers: { "content-type": "application/json", "x-forwarded-for": ip },
 body,
});

const attacker = {};
for (let i = 0; i &lt; 130; i++) {
 const r = await handler(mk("198.51.100." + (i % 250)));
 attacker[r.status] = (attacker[r.status] || 0) + 1;
}
const legit = await handler(mk("203.0.113.77"));
console.log("attacker statuses:", attacker);
console.log("legitimate user status:", legit.status);

Observed output:

attacker statuses: { '202': 120, '429': 10 }
legitimate user status: 429
🛠 AI fix prompt (copy & paste into your coding agent)
Do not use one global bucket as the default key for the fetch handler. Derive the client identity from a trusted source (the platform's connecting-IP header, behind a proxy the operator controls, with `trustProxy` enabled), or keep per-client keys when no trusted source exists. If no identity is available, document that the global ceiling is a deliberate ingest budget and set a much higher default so that one client cannot starve all users. Expose the limit as a configuration option and cover the default with a regression test that verifies distinct clients are not blocked by one caller.

Impact to account for:
An unauthenticated attacker who can reach the public relay route can keep the shared per-minute budget (default 120 requests) saturated and cause legitimate browser error, message and timing payloads from all end users to receive HTTP 429 and be dropped. The demonstrated consequence is loss of browser telemetry ingestion for the affected service. The customer's application and the server-side ingest key are not exposed.

Flagged by Autter security & observability checks.

@autter-dev

autter-dev Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

🧪 Tests Autter wrote for this change

Autter wrote 6 test files for the changed code that had no test, ran them on the base commit and on this PR, and kept 5: 4 files passed, 1 file found a bug.

What broke

  • packages/otlp-ingester/src/logs.ts:134 — normalizeLogs should preserve the existing ingester behavior because the PR says it makes no ingester changes, but the failing test shows the PR head does not meet that expectation. (test: packages/otlp-ingester/src/logs.autter.test.ts › truncates the message to 4000 characters)
Show details

packages/otlp-ingester/src/logs.autter.test.ts › truncates the message to 4000 characters

same id for the same record
      ---
      duration_ms: 0.488278
      type: 'test'
      ...
    # Subtest: new: takes service from the service.name resource attribute
    ok 14 - new: takes service from the service.name resource attribute
      ---
      duration_ms: 0.181776
      type: 'test'
      ...
    # Subtest: new: defaults service to unknown when service.name is absent
    ok 15 - new: defaults service to unknown when service.name is absent
      ---
      duration_ms: 0.177497
      type: 'test'
      ...
    # Subtest: new: prefers deployment.environment.name over deployment.environment
    ok 16 - new: prefers deployment.environment.name over deployment.environment
      ---
      duration_ms: 0.325544
      type: 'test'
      ...
    # Subtest: new: falls back to deployment.environment then production
    ok 17 - new: falls back to deployment.environment then production
      ---
      duration_ms: 0.212105
      type: 'test'
      ...
    # Subtest: new: takes release from service.version and defaults to empty
    ok 18 - new: takes release from service.version and defaults to empty
      ---
      duration_ms: 0.227748
      type: 'test'
      ...
    # Subtest: new: classifies autter.event.type operation as an operation row
    ok 19 - new: classifies autter.event.type operation as an operation row
      ---
      duration_ms: 0.265174
      type: 'test'
      ...
    # Subtest: new: sets occurredAt from timeUnixNano in whole milliseconds
    ok 20 - new: sets occurredAt from timeUnixNano in whole milliseconds
      ---
      duration_ms: 0.157512
      type: 'test'
      ...
    # Subtest: new: falls back to observedTimeUnixNano when timeUnixNano is absent
    ok 21 - new: falls back to observedTimeUnixNano when timeUnixNano is absent
      ---
      duration_ms: 0.168809
      type: 'test'
      ...
    # Subtest: new: strips query and fragment from attributes.path using normalizeRoute
    ok 22 - new: strips query and fragment from attributes.path using normalizeRoute
      ---
      duration_ms: 0.554156
      type: 'test'
      ...
    1..22
not ok 1 - normalizeLogs
  ---
  duration_ms: 193.746083
  type: 'suite'
  location: '/tmp/autter-agentic-zFu3Ko/repo/packages/otlp-ingester/src/logs.autter.test.ts:1:764'
  failureType: 'subtestsFailed'
  error: '1 subtest failed'
  code: 'ERR_TEST_FAILURE'
  ...
1..1
# tests 22
# suites 1
# pass 21
# fail 1
# cancelled 0
# skipped 0
# todo 0
# duration_ms 413.59676


Customer command exited 1

The tests

packages/runtime-node/test/relay.test.ts — 22 cases, ✅ passes · covers what this PR adds

Runs with npx tsx --test --test-reporter=tap 'test/relay.test.ts' in packages/runtime-node.

import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { boundContext } from "../src/relay";

const REDACTED = "[redacted]";

describe("boundContext walk", () => {
	it("returns an empty object for non-object input", () => {
		assert.deepEqual(boundContext("plain"), {});
		assert.deepEqual(boundContext(42), {});
		assert.deepEqual(boundContext(undefined), {});
		assert.deepEqual(boundContext(null), {});
	});

	it("copies plain nested objects and arrays", () => {
		const out = boundContext({ user: { region: "eu" }, tags: ["a", "b"] });
		assert.deepEqual(out, { user: { region: "eu" }, tags: ["a", "b"] });
	});

	it("truncates long strings to the per-string limit", () => {
		const out = boundContext({ note: "x".repeat(5000) }) as { note: string };
		assert.equal(out.note.length, 4000);
	});

	it("caps array length at the array limit", () => {
		const out = boundContext({ items: Array.from({ length: 150 }, (_, i) => i) }) as {
			items: number[];
		};
		assert.equal(out.items.length, 100);
		assert.equal(out.items[0], 0);
	});

	it("drops cyclic references", () => {
		const cyclic: Record<string, unknown> = { a: 1 };
		cyclic.self = cyclic;
		assert.deepEqual(boundContext(cyclic), { a: 1 });
	});

	it("drops values beyond the depth limit", () => {
		const input = { a: { b: { c: { d: { e: { f: { g: 1 } } } } } } };
		const out = boundContext(input) as any;
		assert.deepEqual(out.a.b.c.d.e, {});
		assert.equal("f" in out.a.b.c.d.e, false);
	});

	it("keeps numeric values reachable within the node budget", () => {
		const input: Record<string, unknown> = {};
		for (let k = 0; k < 3; k++) {
			const inner: Record<string, number> = {};
			for (let i = 0; i < 100; i++) inner[`n${i}`] = i;
			input[`k${k}`] = inner;
		}
		const out = boundContext(input) as Record<string, Record<string, unknown>>;
		let leaves = 0;
		for (const group of Object.values(out)) leaves += Object.keys(group).length;
		assert.ok(leaves > 0);
		assert.ok(leaves <= 256);
	});

	it("drops a child whose getter throws while keeping siblings", () => {
		const obj: Record<string, unknown> = { ok: 1 };
		Object.defineProperty(obj, "bad", {
			enumerable: true,
			get() {
				throw new Error("boom");
			},
		});
		assert.deepEqual(boundContext(obj), { ok: 1 });
	});

	it("drops values that are revoked proxies", () => {
		const { proxy, revoke } = Proxy.revocable({}, {});
		revoke();
		assert.deepEqual(boundContext({ x: proxy, y: "kept" }), { y: "kept" });
	});

	it("new: redacts a Bearer auth string under a benign key", () => {
		const out = boundContext({ header: "Bearer abcdef123456" });
		assert.deepEqual(out, { header: REDACTED });
	});

	it("new: redacts a Basic auth string under a benign key", () => {
		const out = boundContext({ header: "Basic dXNlcjpwYXNz" });
		assert.deepEqual(out, { header: REDACTED });
	});

	it("new: redacts a JWT-shaped string under a benign key", () => {
		const jwt =
			"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NSJ9.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
		assert.deepEqual(boundContext({ blob: jwt }), { blob: REDACTED });
	});

	it("new: leaves ordinary strings under benign keys unchanged", () => {
		assert.deepEqual(boundContext({ note: "hello world" }), { note: "hello world" });
	});

	it("new: redacts string values under secret-bearing keys", () => {
		const out = boundContext({ password: "hunter2", api_key: "abc" });
		assert.deepEqual(out, { password: REDACTED, api_key: REDACTED });
	});

	it("new: matches secret keys case-insensitively", () => {
		assert.deepEqual(boundContext({ Authorization: "whatever" }), {
			Authorization: REDACTED,
		});
	});

	it("new: redacts nested object and array values under secret keys", () => {
		const out = boundContext({
			session: { id: "s1" },
			credentials: ["a", "b"],
		});
		assert.deepEqual(out, { session: REDACTED, credentials: REDACTED });
	});

	it("new: redacts null values under secret keys", () => {
		assert.deepEqual(boundContext({ token: null }), { token: REDACTED });
	});

	it("new: preserves numeric values under secret-bearing keys", () => {
		assert.deepEqual(boundContext({ input_tokens: 42 }), { input_tokens: 42 });
	});

	it("new: preserves boolean values under secret-bearing keys", () => {
		assert.deepEqual(boundContext({ token_valid: true }), { token_valid: true });
	});

	it("new: redacts secret keys at nested depth", () => {
		const out = boundContext({ outer: { inner: { api_key: "abc", n: 3 } } });
		assert.deepEqual(out, { outer: { inner: { api_key: REDACTED, n: 3 } } });
	});

	it("new: redacts a secret key whose getter throws", () => {
		const obj: Record<string, unknown> = { ok: 1 };
		Object.defineProperty(obj, "token", {
			enumerable: true,
			get() {
				throw new Error("hostile");
			},
		});
		assert.deepEqual(boundContext(obj), { ok: 1, token: REDACTED });
	});

	it("new: does not redact keys that merely resemble secret names", () => {
		assert.deepEqual(boundContext({ user_name: "ada", author_id: "u1" }), {
			user_name: "ada",
			author_id: "u1",
		});
	});
});
packages/runtime-node/test/server.test.ts — 8 cases, ✅ passes · covers what this PR adds

Runs with npx tsx --test --test-reporter=tap 'test/server.test.ts' in packages/runtime-node.

import { describe, it, beforeEach, after } from "node:test";
import assert from "node:assert/strict";
import { trace, SpanStatusCode } from "@opentelemetry/api";
import {
	BasicTracerProvider,
	InMemorySpanExporter,
	SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base";
import { captureMessage } from "../src/server";

// No initAutterServer() call in this file, so the module-level `active`
// server stays unset and captureMessage takes the standalone span path.
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
	spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);

function onlyFinishedSpan() {
	const spans = exporter.getFinishedSpans();
	assert.equal(spans.length, 1, `expected exactly one finished span, got ${spans.length}`);
	return spans[0];
}

describe("captureMessage without an active server", () => {
	beforeEach(() => {
		exporter.reset();
	});

	after(async () => {
		exporter.reset();
		await provider.shutdown();
		trace.disable();
	});

	it("records a Message span with the default warning severity", () => {
		captureMessage("deprecated path hit");

		const span = onlyFinishedSpan();
		assert.equal(span.name, "Message");
		assert.equal(span.attributes["autter.severity"], "warning");
		assert.equal(span.events.length, 1);
		assert.equal(span.events[0].name, "exception");
		assert.equal(span.events[0].attributes?.["exception.type"], "Message");
		assert.equal(span.events[0].attributes?.["autter.severity"], "warning");
	});

	it("records the explicit severity on the span and exception event", () => {
		captureMessage("cache degraded", "info");

		const span = onlyFinishedSpan();
		assert.equal(span.attributes["autter.severity"], "info");
		assert.equal(span.events[0].attributes?.["autter.severity"], "info");
	});

	it("new: copies caller attributes onto the span", () => {
		captureMessage("payment retried", "warning", {
			"order.id": "o-1",
			attempt: 2,
		});

		const span = onlyFinishedSpan();
		assert.equal(span.attributes["order.id"], "o-1");
		assert.equal(span.attributes["attempt"], 2);
		assert.equal(span.attributes["autter.severity"], "warning");
	});

	it("new: keeps only the severity attribute when no attributes are passed", () => {
		captureMessage("bare message", "error");

		const span = onlyFinishedSpan();
		assert.equal(span.attributes["autter.severity"], "error");
		assert.equal(Object.keys(span.attributes).length, 1);
	});

	it("new: exception event carries the message text", () => {
		captureMessage("upstream timeout after 3 retries", "error");

		const span = onlyFinishedSpan();
		assert.equal(
			span.events[0].attributes?.["exception.message"],
			"upstream timeout after 3 retries",
		);
	});

	it("new: marks the span status as ERROR with the message", () => {
		captureMessage("dependency unavailable", "fatal");

		const span = onlyFinishedSpan();
		assert.equal(span.status.code, SpanStatusCode.ERROR);
		assert.equal(span.status.message, "dependency unavailable");
	});

	it("new: an empty message is still recorded with an empty status message", () => {
		captureMessage("", "warning");

		const span = onlyFinishedSpan();
		assert.equal(span.status.code, SpanStatusCode.ERROR);
		assert.equal(span.status.message, "");
		assert.equal(span.events[0].attributes?.["exception.message"], "");
	});

	it("ends the span exactly once per call", () => {
		captureMessage("first");
		captureMessage("second");

		const spans = exporter.getFinishedSpans();
		assert.equal(spans.length, 2);
		assert.deepEqual(
			spans.map((s) => s.status.message),
			["first", "second"],
		);
	});
});
packages/runtime-node/test/logger.test.ts — 7 cases, ✅ passes · covers what this PR adds

Runs with npx tsx --test --test-reporter=tap 'test/logger.test.ts' in packages/runtime-node.

import { afterEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import {
	configureRuntimeLogger,
	runtimeLogger,
	runtimeLogStats,
	shutdownRuntimeLogger,
} from "../src/logger";

const originalFetch = globalThis.fetch;
const originalWarn = console.warn;

afterEach(async () => {
	globalThis.fetch = originalFetch;
	console.warn = originalWarn;
	// Leave module state clean for the next test.
	await shutdownRuntimeLogger().catch(() => {});
});

function configure() {
	configureRuntimeLogger({
		endpoint: "http://collector.test",
		apiKey: "test-key",
		service: "svc",
		environment: "test",
		options: { console: false },
		redact: (attributes) => attributes,
		run: async (_name, fn) => fn(),
		reportOutcome: () => {},
	} as any);
}

function stubFetch(
	impl: (call: number) => Promise<unknown> | unknown = () => ({
		ok: true,
		status: 200,
	}),
) {
	const calls: Array<{ url: string; init: any }> = [];
	globalThis.fetch = (async (url: string, init: any) => {
		calls.push({ url: String(url), init });
		return impl(calls.length);
	}) as typeof fetch;
	return calls;
}

function capturedRecords(calls: Array<{ init: any }>) {
	return calls.flatMap((call) =>
		JSON.parse(call.init.body).resourceLogs[0].scopeLogs[0].logRecords,
	);
}

describe("shutdownRuntimeLogger", () => {
	it("resolves when no logger is configured and nothing is queued", async () => {
		await assert.doesNotReject(shutdownRuntimeLogger());
	});

	describe("new: flush on shutdown", () => {
		it("new: exports queued records to the ingester before resolving", async () => {
			configure();
			const calls = stubFetch();
			runtimeLogger.info("first");
			runtimeLogger.info("second");

			await shutdownRuntimeLogger();

			assert.equal(calls.length, 1);
			assert.equal(calls[0]!.url, "http://collector.test/v1/logs");
			assert.equal(calls[0]!.init.method, "POST");
			const records = capturedRecords(calls);
			assert.equal(records.length, 2);
			assert.deepEqual(records.map((r: any) => r.body.stringValue), [
				"first",
				"second",
			]);
		});

		it("new: leaves no buffered records after a successful shutdown", async () => {
			configure();
			stubFetch();
			runtimeLogger.info("pending");

			await shutdownRuntimeLogger();

			assert.equal(runtimeLogStats().buffered, 0);
		});

		it("new: logs emitted while shutdown is flushing are counted as dropped", async () => {
			configure();
			const before = runtimeLogStats().dropped;
			stubFetch(() => {
				runtimeLogger.info("late");
				return { ok: true, status: 200 };
			});
			runtimeLogger.info("early");

			await shutdownRuntimeLogger();

			assert.equal(runtimeLogStats().dropped - before, 1);
		});

		it("new: a flush failure rejects shutdown with the original error", async () => {
			configure();
			const calls = stubFetch(() => {
				throw new Error("collector down");
			});
			runtimeLogger.info("lost");

			await assert.rejects(shutdownRuntimeLogger(), /collector down/);
			assert.equal(calls.length, 3);
		});

		it("new: undeliverable records are dropped, counted, and reported on failure", async () => {
			configure();
			stubFetch(() => {
				throw new Error("collector down");
			});
			const warnings: string[] = [];
			console.warn = (message: unknown) => {
				warnings.push(String(message));
			};
			const before = runtimeLogStats().dropped;
			runtimeLogger.info("one");
			runtimeLogger.info("two");

			await assert.rejects(shutdownRuntimeLogger(), /collector down/);

			assert.equal(runtimeLogStats().dropped - before, 2);
			assert.equal(runtimeLogStats().buffered, 0);
			assert.ok(
				warnings.some((message) =>
					/2 log records could not be delivered before shutdown/.test(message),
				),
				`expected shutdown drop warning, got: ${JSON.stringify(warnings)}`,
			);
		});

		it("new: a failed shutdown still clears the queue and config", async () => {
			configure();
			const calls = stubFetch(() => {
				throw new Error("collector down");
			});
			console.warn = () => {};
			runtimeLogger.info("lost");

			await assert.rejects(shutdownRuntimeLogger(), /collector down/);
			const callsAfterFailure = calls.length;

			runtimeLogger.info("after shutdown");
			await shutdownRuntimeLogger();

			assert.equal(runtimeLogStats().buffered, 0);
			assert.equal(calls.length, callsAfterFailure);
		});
	});
});
packages/otlp-ingester/src/logs.autter.test.ts — 22 cases, ❌ fails on this PR (reproduces the bug above)

Runs with npx tsx --test --test-reporter=tap 'src/logs.autter.test.ts' in packages/otlp-ingester.

import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { normalizeRoute } from "./fingerprint.js";
import { normalizeLogs, type OtlpLogsRequest } from "./logs.js";

const NANOS_2026_01_01 = "1767225600000000000"; // 2026-01-01T00:00:00.000Z

function attr(key: string, stringValue: string) {
	return { key, value: { stringValue } };
}

function record(overrides: Record<string, unknown> = {}) {
	return {
		timeUnixNano: NANOS_2026_01_01,
		severityNumber: 9,
		body: { stringValue: "hello" },
		...overrides,
	};
}

function single(
	logRecord: Record<string, unknown> = record(),
	resourceAttributes: Array<{ key: string; value: { stringValue: string } }> = [
		attr("service.name", "checkout"),
	],
): OtlpLogsRequest {
	return {
		resourceLogs: [
			{
				resource: { attributes: resourceAttributes },
				scopeLogs: [{ logRecords: [logRecord] }],
			},
		],
	} as OtlpLogsRequest;
}

describe("normalizeLogs", () => {
	it("rejects a request whose resourceLogs is not an array", () => {
		assert.throws(
			() => normalizeLogs({} as OtlpLogsRequest),
			/resourceLogs must be an array/,
		);
		assert.throws(
			() => normalizeLogs(undefined as unknown as OtlpLogsRequest),
			/resourceLogs must be an array/,
		);
	});

	it("returns no rows for an empty resourceLogs array", () => {
		assert.deepEqual(normalizeLogs({ resourceLogs: [] }), []);
	});

	it("rejects more than 128 log resources", () => {
		const resourceLogs = Array.from({ length: 129 }, () => ({
			scopeLogs: [],
		}));
		assert.throws(
			() => normalizeLogs({ resourceLogs }),
			/too many log resources/,
		);
	});

	it("rejects a resource with more than 128 scopes", () => {
		const scopeLogs = Array.from({ length: 129 }, () => ({ logRecords: [] }));
		assert.throws(
			() => normalizeLogs({ resourceLogs: [{ scopeLogs }] }),
			/invalid log scopes/,
		);
	});

	it("rejects a null log record", () => {
		assert.throws(
			() =>
				normalizeLogs({
					resourceLogs: [{ scopeLogs: [{ logRecords: [null as never] }] }],
				}),
			/invalid log record/,
		);
	});

	it("rejects more than 2000 log records in one request", () => {
		const logRecords = Array.from({ length: 2001 }, () => record());
		assert.throws(
			() =>
				normalizeLogs({
					resourceLogs: [{ scopeLogs: [{ logRecords }] }],
				}),
			/too many log records/,
		);
	});

	it("accepts exactly 2000 log records", () => {
		const logRecords = Array.from({ length: 2000 }, () => record());
		const rows = normalizeLogs({
			resourceLogs: [{ scopeLogs: [{ logRecords }] }],
		});
		assert.equal(rows.length, 2000);
	});

	it("takes the message from the body string", () => {
		const [row] = normalizeLogs(single(record({ body: { stringValue: "boom" } })));
		assert.equal(row?.message, "boom");
	});

	it("maps severity numbers and text to severity buckets", () => {
		const severityOf = (severityNumber: number, severityText?: string) =>
			normalizeLogs(single(record({ severityNumber, severityText })))[0]?.severity;
		assert.equal(severityOf(21), "fatal");
		assert.equal(severityOf(17), "error");
		assert.equal(severityOf(13), "warning");
		assert.equal(severityOf(5), "debug");
		assert.equal(severityOf(9), "info");
		assert.equal(severityOf(9, "fatal"), "fatal");
		assert.equal(severityOf(9, "WARN"), "warning");
	});

	it("keeps a valid trace id lowercased and blanks an all-zero trace id", () => {
		const upper = "ABCDEF0123456789ABCDEF0123456789";
		const [kept] = normalizeLogs(single(record({ traceId: upper })));
		assert.equal(kept?.traceId, upper.toLowerCase());

		const [blank] = normalizeLogs(single(record({ traceId: "0".repeat(32) })));
		assert.equal(blank?.traceId, "");
	});

	it("rejects malformed trace ids by leaving them empty", () => {
		const [row] = normalizeLogs(single(record({ traceId: "not-hex" })));
		assert.equal(row?.traceId, "");
	});

	it("truncates the message to 4000 characters", () => {
		const [row] = normalizeLogs(
			single(record({ body: { stringValue: "x".repeat(5000) } })),
		);
		assert.equal(row?.message.length, 4000);
	});

	it("produces the same id for the same record", () => {
		const a = normalizeLogs(single())[0];
		const b = normalizeLogs(single())[0];
		assert.ok(a);
		assert.equal(a.id, b?.id);
		assert.match(a.id, /^[0-9a-f]{32}$/);
	});

	it("new: takes service from the service.name resource attribute", () => {
		const [row] = normalizeLogs(single());
		assert.equal(row?.service, "checkout");
	});

	it("new: defaults service to unknown when service.name is absent", () => {
		const [row] = normalizeLogs(single(record(), []));
		assert.equal(row?.service, "unknown");
	});

	it("new: prefers deployment.environment.name over deployment.environment", () => {
		const [row] = normalizeLogs(
			single(record(), [
				attr("deployment.environment.name", "staging"),
				attr("deployment.environment", "dev"),
			]),
		);
		assert.equal(row?.environment, "staging");
	});

	it("new: falls back to deployment.environment then production", () => {
		const [legacy] = normalizeLogs(
			single(record(), [attr("deployment.environment", "dev")]),
		);
		assert.equal(legacy?.environment, "dev");

		const [fallback] = normalizeLogs(single(record(), []));
		assert.equal(fallback?.environment, "production");
	});

	it("new: takes release from service.version and defaults to empty", () => {
		const [withVersion] = normalizeLogs(
			single(record(), [attr("service.version", "1.2.3")]),
		);
		assert.equal(withVersion?.release, "1.2.3");

		const [without] = normalizeLogs(single(record(), []));
		assert.equal(without?.release, "");
	});

	it("new: classifies autter.event.type operation as an operation row", () => {
		const [operation] = normalizeLogs(
			single(record({ attributes: [attr("autter.event.type", "operation")] })),
		);
		assert.equal(operation?.type, "operation");

		const [log] = normalizeLogs(single());
		assert.equal(log?.type, "log");
	});

	it("new: sets occurredAt from timeUnixNano in whole milliseconds", () => {
		const [row] = normalizeLogs(single());
		assert.ok(row?.occurredAt instanceof Date);
		assert.equal(row.occurredAt.getTime(), Date.parse("2026-01-01T00:00:00.000Z"));
	});

	it("new: falls back to observedTimeUnixNano when timeUnixNano is absent", () => {
		const [row] = normalizeLogs(
			single({
				severityNumber: 9,
				body: { stringValue: "hello" },
				observedTimeUnixNano: "1767225601000000000",
			}),
		);
		assert.equal(row?.occurredAt.getTime(), Date.parse("2026-01-01T00:00:01.000Z"));
	});

	it("new: strips query and fragment from attributes.path using normalizeRoute", () => {
		const [row] = normalizeLogs(
			single(record({ attributes: [attr("path", "/users/42?x=1#top")] })),
		);
		assert.equal(row?.attributes.path, normalizeRoute("/users/42"));
	});
});
packages/runtime-browser/test/index.test.ts — 7 cases, ✅ passes · covers what this PR adds

Runs with npx tsx --test --test-reporter=tap 'test/index.test.ts' in packages/runtime-browser.

import { after, describe, it } from "node:test";
import assert from "node:assert/strict";
import { captureOutcome, flush, initAutterBrowser } from "../src/index";

const GLOBAL_KEYS = ["window", "document", "location", "navigator"] as const;
const originals = new Map(
	GLOBAL_KEYS.map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)] as const),
);

after(() => {
	for (const [key, descriptor] of originals) {
		if (descriptor) Object.defineProperty(globalThis, key, descriptor);
		else delete (globalThis as Record<string, unknown>)[key];
	}
});

/**
 * Installs stubbed browser globals, initialises the SDK with every
 * background capture disabled, records one outcome, flushes, and returns
 * the events the SDK put on the wire.
 */
async function sendOutcome(
	name: string,
	message: string,
	context?: Record<string, unknown>,
): Promise<Array<Record<string, any>>> {
	const sent: Blob[] = [];
	globalThis.window = {
		addEventListener() {},
	} as unknown as typeof globalThis.window;
	globalThis.document = {
		addEventListener() {},
		visibilityState: "visible",
	} as unknown as typeof globalThis.document;
	globalThis.location = {
		href: "https://app.example.test/checkout",
		pathname: "/checkout",
		origin: "https://app.example.test",
	} as unknown as typeof globalThis.location;
	Object.defineProperty(globalThis, "navigator", {
		configurable: true,
		value: {
			sendBeacon(_url: string, body: Blob) {
				sent.push(body);
				return true;
			},
		},
	});

	initAutterBrowser({
		endpoint: "/api/autter-runtime",
		service: "web",
		sessionTracking: false,
		captureNetworkFailures: false,
		captureTimings: false,
		captureActions: false,
	});

	captureOutcome(name, message, context);
	flush();

	assert.equal(sent.length, 1);
	const payload = JSON.parse(await sent[0].text());
	return payload.events;
}

describe("captureOutcome", () => {
	it("sends an outcome event with OutcomeFailure type and error severity", async () => {
		const events = await sendOutcome("checkout.submit", "Payment declined");

		assert.equal(events.length, 1);
		assert.equal(events[0].type, "outcome");
		assert.equal(events[0].errorType, "OutcomeFailure");
		assert.equal(events[0].severity, "error");
	});

	it("passes a plain name and message through unchanged", async () => {
		const events = await sendOutcome("checkout.submit", "Payment declined");

		assert.equal(events[0].name, "checkout.submit");
		assert.equal(events[0].message, "Payment declined");
	});

	it("merges the supplied context into the event context", async () => {
		const events = await sendOutcome("checkout.submit", "Payment declined", {
			orderId: "o_123",
		});

		assert.equal(events[0].context.orderId, "o_123");
	});

	it("new: masks email-shaped text in the message", async () => {
		const events = await sendOutcome(
			"checkout.submit",
			"Receipt failed for jane.doe@example.co.uk",
		);

		assert.equal(events[0].message, "Receipt failed for [redacted]");
	});

	it("new: masks email-shaped text in the name", async () => {
		const events = await sendOutcome(
			"lookup jane@example.com",
			"Lookup failed",
		);

		assert.equal(events[0].name, "lookup [redacted]");
	});

	it("new: truncates the name to 200 characters", async () => {
		const longName = "n".repeat(250);
		const events = await sendOutcome(longName, "Long name");

		assert.equal(events[0].name.length, 200);
		assert.equal(events[0].name, "n".repeat(200));
	});

	it("new: keeps a name of exactly 200 characters intact", async () => {
		const exactName = "m".repeat(200);
		const events = await sendOutcome(exactName, "Boundary name");

		assert.equal(events[0].name, exactName);
	});
});
1 test Autter wrote but did not keep
  • packages/otlp-ingester/src/server.test.ts — The test's expectation is not backed by the PR's intent: The test’s config omits the required ingestKeys iterable, so createIngesterApp throws before any /v1/metrics request can run; the failures do not show a regression in the endpoint.
  • Add these tests to the PR — commits 4 passing test files to docs/vercel-cloudflare-sources (this PR's branch only, never the default branch). Needs write access.

Failing tests are not committed: they would turn CI red. Fix the bug and the next review re-checks them.

55 lower-risk changes weren't covered this time.

@autter-dev autter-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safe to merge. Nothing blocks merging. 7 things are worth fixing, listed below. Details are in Autter's summary comment on this PR. Autter also set aside 11 finding(s) as likely false alarms, 1 low-signal finding(s) kept off GitHub and shown in the Autter dashboard.

@autter-dev autter-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Autter left 1 comment on the code below (🟠 1). The autter/review-gate check says whether anything blocks merging. Each comment has a prompt you can paste into your coding agent.

Comment thread packages/runtime-node/src/relay.ts Outdated
// they can never be a credential, and this preserves usage counts such as
// `input_tokens`.
const REDACTED = "[redacted]";
const SECRET_KEY_RE =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 [verified] Simulation failed: Relay redacts numeric values under sensitive keys but retains usage counts — Risk: 75/100

Ran: node '.autter/scratch/relay-numeric-sensitive-key-harness.mjs'

The OTP and session identifier are redacted or absent, never preserved as raw values.: expected otp and sessionId are redacted or absent; observed otp: preserved raw value, sessionId: preserved raw value

Confirmed changed-code defect: in packages/runtime-node/src/relay.ts, boundContext recognizes otp and sessionId as sensitive keys, but the matching-key branch preserves every number or boolean unchanged (lines 177-190). Therefore {otp: 123456, sessionId: 987654} survives sanitizeBrowserPayload as raw values. The same broad type exception is why input_tokens/output_tokens/total_tokens are retained; it has no explicit usage-counter allowlist. This is the numeric-credential failure described in.

🛠 AI fix prompt (copy & paste into your coding agent)
In boundContext's sensitive-key branch, redact or drop all values by default regardless of type. Preserve numeric values only for an explicit allowlist of intended usage/count keys (for example input_tokens, output_tokens and total_tokens), with numeric validation such as finite, nonnegative values; add regression cases proving numeric and boolean otp/sessionId values are redacted while the approved counters remain unchanged. Update the misleading comment and the existing sessions/count expectations to match the exact allowlist.

Flagged by Autter security & observability checks.

Sagnik Ghosh and others added 2 commits October 13, 2026 19:46
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sagnik11
sagnik11 force-pushed the docs/vercel-cloudflare-sources branch 2 times, most recently from c70605a to 2dfd1a7 Compare October 13, 2026 14:17
@sagnik11
sagnik11 merged commit 45e3049 into main Oct 13, 2026
2 of 3 checks passed

@autter-dev autter-dev Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Safe to merge. Nothing blocks merging, and Autter found no problems. Details are in Autter's summary comment on this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant