Skip to content

feat(observability): harden error ingest - #1972

Open
mantrakp04 wants to merge 1 commit into
stack/observability-otel-runtimefrom
stack/observability-error-ingest
Open

feat(observability): harden error ingest#1972
mantrakp04 wants to merge 1 commit into
stack/observability-otel-runtimefrom
stack/observability-error-ingest

Conversation

@mantrakp04

@mantrakp04 mantrakp04 commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Harden envelope parsing, policy evaluation, client reports, and protocol projections.
  • Make attachment and request-context boundaries fail safely and predictably.
  • Keep SDK error capture, processors, scopes, and attachment behavior aligned with the backend protocol.

Stack

Base: OTel runtime and SDK
Next: source maps, releases, and issues

Test plan

  • Run error-ingest unit tests.
  • Run analytics batch, envelope, attachment, and Sentry-compatible endpoint tests.
  • Run SDK lint and typecheck with the repository development environment.

Summary by cubic

Hardens error ingest and attachment routes to enforce typed error mapping, stricter bounds, and secret scrubbing, aligning SDK/runtime behavior with the backend protocol. This stops leaking internal error messages, caps pathological inputs, and preserves distributed tracing context.

  • Envelopes and client reports: map only ErrorIngestEnvelopeError/ErrorIngestClientReportParseError to 400; let other faults surface as generic 500s; redact secret-bearing text at parse time.
  • Outcomes: replace batch container with summarizeErrorIngestOutcomes; protocol adapter reports per-signal OTLP rejections (logs vs. traces) instead of a flat total.
  • Attachments: map typed 404/409 (ErrorAttachmentNotFoundError, ErrorAttachmentConflictError); enforce validateErrorAttachmentUpload; cap storage key to 1024 bytes and repository reads to MAX_ERROR_ATTACHMENTS_PER_EVENT; guard routes with assertObservabilityEnabled.
  • Scrubbing and request context: scrub secrets in serialized JSON and protocol-relative URL credentials; scrub and bound URL path segments and cookie names; keep built-in drop policy authoritative over urlKeys.
  • Event/trace adapters: fall back to receipt time for out-of-range numeric timestamps; emit span identity only when both trace_id and span_id exist; retain parent_span_id for distributed roots.
  • OTLP/logs: move writers under @/lib/otlp/*; logs surface message in body JSON, not a message column.
  • SDK/runtime: enforce processor limits per source (configured vs. scope), cap initial breadcrumbs like addBreadcrumb, trim deep cause chains to the exception byte budget, avoid extra attachment byte copies; document fingerprint as the grouping token.

Migration

  • Replace imports from @/lib/telemetry-ingest with @/lib/error-ingest; update OTLP imports to @/lib/otlp/*.
  • If you used createErrorIngestBatchOutcome, switch to summarizeErrorIngestOutcomes.
  • Update analytics log queries to read message from body.value.
  • Update config overrides: observability.errorIngest.finalScrub.dropKeys/urlKeys now map rule ids to selectors (e.g., dropEmail: "user.email"). Rule ids must be dotless; unsafe selectors are rejected.
  • Ensure attachment producers keep storage keys within 1024 bytes (long tenant/project/branch ids or excessive percent-encoding will now be rejected).

Written for commit 6bb09ae. Summary will update on new commits.

Review in cubic

Keep envelope, policy, attachment, request-context, and SDK error-capture behavior together as the protocol boundary before issue-domain consumers.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
hexclave-marshal Error Error Aug 15, 2026 2:44am
stack-auth-hosted-components Ready Ready Preview Aug 15, 2026 2:44am
stack-auth-internal-tool Ready Ready Preview Aug 15, 2026 2:44am
stack-auth-mcp Ready Ready Preview Aug 15, 2026 2:44am
stack-auth-skills Ready Ready Preview Aug 15, 2026 2:44am
stack-backend Error Error Aug 15, 2026 2:44am
stack-dashboard Error Error Aug 15, 2026 2:44am
stack-demo Ready Ready Preview Aug 15, 2026 2:44am
stack-preview-backend Error Error Aug 15, 2026 2:44am
stack-preview-dashboard Error Error Aug 15, 2026 2:44am

@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 646ac539-82b5-40d4-a6f9-28f78cbb81e9

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@vercel vercel Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Additional Suggestion:

Assigning a bare vi.fn() to the overloaded window.scrollTo fails typechecking (TS2322), breaking the dashboard build.

Fix on Vercel

Comment on lines +3 to 7
import { assertObservabilityEnabled } from "@/lib/issues/observability-gate";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
import { assertObservabilityEnabled } from "@/lib/issues/observability-gate";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@hexclave/shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupBoolean, yupMixed, yupNumber, yupObject, yupString } from "@hexclave/shared/dist/schema-fields";
import { StatusError } from "@hexclave/shared/dist/utils/errors";
function assertObservabilityEnabled(tenancy: { config: { apps: { installed: { observability?: { enabled?: boolean } } } } }): void {
if (!tenancy.config.apps.installed.observability?.enabled) throw new KnownErrors.ObservabilityNotEnabled();
}

The attachments route imports assertObservabilityEnabled from the non-existent module @/lib/issues/observability-gate, causing an ERR_MODULE_NOT_FOUND build failure.

Fix on Vercel

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

18 issues found across 42 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="apps/backend/src/app/api/latest/analytics/events/batch/route.tsx">

<violation number="1" location="apps/backend/src/app/api/latest/analytics/events/batch/route.tsx:511">
P2: When a versioned batch contains `$log`, this descriptor labels it as an `event`. Filtered or rate-limited logs are therefore persisted as category `error` instead of `log_item`; assign `$log` descriptors the `log` item type.</violation>

<violation number="2" location="apps/backend/src/app/api/latest/analytics/events/batch/route.tsx:761">
P2: For a versioned batch that mixes `$page-view`/`$click`/custom events or spans with `$error`/`$log` items, the response `ingest` block and the persisted client-report ledger only reflect the error/log items. `createLegacyBatchProtocolProjection` fully ignores `eventCount`/`spanCount` when policy outcomes are supplied, and `policyDecision.outcomes` covers only the error-pipeline items, so the accepted analytics events and spans are absent from `ingest.counts` and the projection. At the same time `inserted` now reports the pre-filter carried count (`body.events?.length`), so for a batch with dropped/rejected errors this field over-states the stored count. Net effect: for mixed batches neither field reports how many events were actually stored. Consider merging the count-based accepted projection with the error outcomes (e.g. pass all items' outcomes, or append accepted status for the product/spans) so the ledger and response reflect the full batch.</violation>
</file>

<file name="apps/backend/src/lib/error-ingest/error-ingest-protocol-adapter.test.ts">

<violation number="1" location="apps/backend/src/lib/error-ingest/error-ingest-protocol-adapter.test.ts:56">
P3: The added comment says "The other five rejections are events/unknown items that no OTLP signal may claim," but only three rejected items are events/unknown (d `rate_limited`, e `rejected`, h `dropped`). The other two rejected items (b `filtered` log, c `filtered` span) are the ones the signals claim. "five" should be "three".</violation>
</file>

<file name="apps/e2e/tests/backend/endpoints/api/v1/analytics-sentry-envelope.test.ts">

<violation number="1" location="apps/e2e/tests/backend/endpoints/api/v1/analytics-sentry-envelope.test.ts:124">
P2: This retry assertion cannot detect the double-counting it claims to verify. All four asserted fields are deterministically recomputed on each request: batch_id is `envelope:event:${eventId}` (derived straight from the event id in error-ingest-envelope.ts:750), inserted is `acceptedEvents.length + acceptedTransactions` (route.ts, always 1 here regardless of storage state), counts.accepted is recomputed from item outcomes, and idempotency_key is `idempotencyKey(batchId, items)`. A second POST of the identical envelope therefore returns byte-identical values even if ClickHouse or the client-report ledger double-inserts or fails to dedupe entirely, so this test passes trivially and gives false confidence about the dedup behavior the comment describes. To actually validate idempotency, query the store after the retry (e.g. assert the event query for this event_id returns exactly one row, or query the client-report ledger) rather than comparing the freshly recomputed response.</violation>
</file>

<file name="apps/backend/src/lib/error-ingest/error-ingest-transaction-adapter.ts">

<violation number="1" location="apps/backend/src/lib/error-ingest/error-ingest-transaction-adapter.ts:188">
P2: When a transaction supplies the all-zero `contexts.trace.parent_span_id`, this line persists an invalid W3C parent ID instead of rejecting the transaction. The envelope parser accepts the value because it checks only the hex pattern, so the resulting span is not a valid root and can disappear from `trace_roots`; validate the parent with `isW3cSpanId` before emitting it and reject malformed transactions.</violation>
</file>

<file name="apps/backend/src/lib/error-ingest/error-ingest-client-reports.ts">

<violation number="1" location="apps/backend/src/lib/error-ingest/error-ingest-client-reports.ts:217">
P2: A client can still place an authorization header or JWT in `idempotency_key`; this check only bounds the field, and the persistence path writes it into the loss ledger. Reject secret-bearing idempotency keys here as well, or derive the raw endpoint key server-side before persistence.</violation>
</file>

<file name="apps/backend/src/app/api/latest/analytics/attachments/[attachment_id]/route.ts">

<violation number="1" location="apps/backend/src/app/api/latest/analytics/attachments/[attachment_id]/route.ts:49">
P2: When the backing object disappears after the HEAD succeeds, the GET 404 bypasses `ErrorAttachmentNotFoundError` and this endpoint returns 500 instead of the promised 404. Normalize storage-layer GET 404s to the not-found error before this route maps them.</violation>
</file>

<file name="apps/backend/src/lib/error-ingest/error-ingest-scrubber.ts">

<violation number="1" location="apps/backend/src/lib/error-ingest/error-ingest-scrubber.ts:69">
P1: When a protocol-relative credential contains an unescaped `@`, this pattern redacts only up to the first `@` and leaks the remaining password suffix. Match the complete userinfo through the authority delimiter, including `@` inside the password, before replacing it.</violation>
</file>

<file name="apps/backend/src/lib/safe-request-context.ts">

<violation number="1" location="apps/backend/src/lib/safe-request-context.ts:168">
P1: When a cookie name percent-encodes PII or JWT delimiters, this call scrubs the escapes instead of the decoded name, so the sensitive name remains in `cookies.names`. Decode each name safely before applying `scrubString`.</violation>

<violation number="2" location="apps/backend/src/lib/safe-request-context.ts:195">
P1: When a URL contains percent-encoded path data, this call leaves it unsanitized: `alice%40example.com` and JWTs with `%2E` bypass the scrubber and enter `context.url`. Decode the pathname safely before calling `scrubString`.</violation>
</file>

<file name="apps/backend/src/app/api/latest/analytics/events/batch/route.test.tsx">

<violation number="1" location="apps/backend/src/app/api/latest/analytics/events/batch/route.test.tsx:72">
P3: The comment claims the payload is "one byte over the cap once serialized", but `{ blob: "x".repeat(64000) }` serializes to 64,011 bytes — 11 over the cap (the `{"blob":"` prefix and trailing `"}` add 11 bytes, and the route's `isPlainObjectWithinLimit` uses `Buffer.byteLength(serialized)`). The assertion itself is correct (still over the cap, so it rejects), but the comment's boundary claim is inaccurate and would mislead anyone reasoning about the exact boundary. Correct the figure or make the payload serialize to exactly cap+1.</violation>
</file>

<file name="apps/backend/src/lib/error-ingest/error-ingest-policy.ts">

<violation number="1" location="apps/backend/src/lib/error-ingest/error-ingest-policy.ts:203">
P1: The new override shape in `parseOverrideKeys` (dotless rule-id keys with string selector values) conflicts with the shared config schema in `packages/shared/src/config/schema.ts`, which still validates `dropKeys`/`urlKeys` as `yupRecord(<dotted-selector>, yupBoolean().isTrue())`. New configs like `{ dropEmail: "user.email" }` (already used in the new tests) are rejected by schema validation because the value must be boolean `true`; previously-valid configs like `{ "user.email": true }` now throw `ErrorIngestPolicyConfigError` at policy parse time. Update the shared schema to accept the new `{ dotlessId: "selector" }` shape (and reject the old boolean shape) so config validation and policy parsing agree.</violation>

<violation number="2" location="apps/backend/src/lib/error-ingest/error-ingest-policy.ts:208">
P1: When an error-ingest policy is loaded from environment configuration, this parser rejects the schema's existing `{selector: true}` overrides because it now requires string values, while the shared schema also rejects the new policy fields. Update the shared schema and migration/serialization contract together; otherwise final scrubbing and the new policy controls cannot be configured through the supported API.</violation>
</file>

<file name="packages/template/src/lib/hexclave-app/apps/implementations/error-capture.ts">

<violation number="1" location="packages/template/src/lib/hexclave-app/apps/implementations/error-capture.ts:186">
P2: This limits frame count but not frame content, so a single adapter value can still exceed the 32KB exception budget and cause the whole error item to be dropped. Bound frame fields or discard frame details under the same aggregate byte budget.</violation>

<violation number="2" location="packages/template/src/lib/hexclave-app/apps/implementations/error-capture.ts:212">
P2: When `captureEvent` receives exception metadata containing a `bigint` or cycle, this byte-accounting call throws before processing and the public capture fails synchronously. Use a guarded serializer that normalizes or drops non-JSON metadata instead of letting adapter data escape the capture boundary.</violation>

<violation number="3" location="packages/template/src/lib/hexclave-app/apps/implementations/error-capture.ts:356">
P3: `boundExceptionValues` sizes each entry from the value BEFORE the primary's `mechanism` is added in `buildErrorEventDataFromNormalized`, and `boundExceptionValue` does not truncate adapter-supplied fields other than type/value/stacktrace. The shipped `exception.values` can therefore exceed the 32KB budget that the new test asserts as an upper bound. Count the final primary mechanism (and any remaining value fields) in the byte accounting so the budget is a real invariant of the serialized array.</violation>
</file>

<file name="apps/backend/src/lib/error-ingest/error-ingest-protocol-adapter.ts">

<violation number="1" location="apps/backend/src/lib/error-ingest/error-ingest-protocol-adapter.ts:533">
P3: In `otlpPartialSuccess` the per-signal counts are obtained via `summarizeErrorIngestOutcomes(...)`, which computes a batch `status`/`reason` (an extra loop over `outcomes.slice(1)`) that is discarded — only `.counts` is used. Extract a `countErrorIngestOutcomes` helper that just builds the counts in one pass and use it here (and in `createErrorIngestProtocolProjection` where the summary status is actually needed).</violation>
</file>

<file name="packages/template/src/lib/hexclave-app/apps/implementations/error-scope.ts">

<violation number="1" location="packages/template/src/lib/hexclave-app/apps/implementations/error-scope.ts:37">
P3: This PR caps initial `breadcrumbs` in `copyScopeData` to close the `createErrorScope(initial)` bypass, but initial `eventProcessors` are still copied unbounded (`[...data.eventProcessors]`, no slice). A scope seeded with more than `MAX_EVENT_PROCESSORS` processors is therefore not capped like breadcrumbs; the new per-source check in `processErrorEvent` then drops the entire capture. Apply the same bound here (`eventProcessors.slice(0, MAX_EVENT_PROCESSORS)`) so oversized initial data is trimmed to the newest `MAX_EVENT_PROCESSORS` processors instead of failing the whole event.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

const SENSITIVE_ASSIGNMENT_PATTERN = /((?:access[-_.]?token|api[-_.]?key|authorization|client[-_.]?secret|cookie|credential|id[-_.]?token|password|passwd|private[-_.]?key|refresh[-_.]?token|secret|session[-_.]?token|signature|token)\s*[:=]\s*)(["']?)(?:(Bearer|Basic|Digest)\s+)?([^\s"'&,;}\]]+)\2/gi;
// The scheme is optional so protocol-relative references (`//user:pass@host`)
// lose their userinfo credentials just like absolute URLs.
const URL_AUTH_PATTERN = /((?:[a-z][a-z\d+.-]*:)?\/\/)(?:[^/@\s]+):(?:[^/@\s]+)@/gi;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a protocol-relative credential contains an unescaped @, this pattern redacts only up to the first @ and leaks the remaining password suffix. Match the complete userinfo through the authority delimiter, including @ inside the password, before replacing it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/error-ingest/error-ingest-scrubber.ts, line 69:

<comment>When a protocol-relative credential contains an unescaped `@`, this pattern redacts only up to the first `@` and leaks the remaining password suffix. Match the complete userinfo through the authority delimiter, including `@` inside the password, before replacing it.</comment>

<file context>
@@ -64,8 +64,13 @@ const URL_KEY_PATTERN = /(?:^|[._-])(?:http[-_.]?target|request[-_.]?(?:target|u
-const SENSITIVE_ASSIGNMENT_PATTERN = /((?:access[-_.]?token|api[-_.]?key|authorization|client[-_.]?secret|cookie|credential|id[-_.]?token|password|passwd|private[-_.]?key|refresh[-_.]?token|secret|session[-_.]?token|signature|token)\s*[:=]\s*)(["']?)(?:(Bearer|Basic|Digest)\s+)?([^\s"'&,;}\]]+)\2/gi;
+// The scheme is optional so protocol-relative references (`//user:pass@host`)
+// lose their userinfo credentials just like absolute URLs.
+const URL_AUTH_PATTERN = /((?:[a-z][a-z\d+.-]*:)?\/\/)(?:[^/@\s]+):(?:[^/@\s]+)@/gi;
+// The optional quote around the key (backreference \2) covers serialized JSON
+// embedded in message strings (`{"password":"..."}`), which the bare-key form
</file context>
Suggested change
const URL_AUTH_PATTERN = /((?:[a-z][a-z\d+.-]*:)?\/\/)(?:[^/@\s]+):(?:[^/@\s]+)@/gi;
const URL_AUTH_PATTERN = /((?:[a-z][a-z\d+.-]*:)?\/\/)(?:[^/@\s?#]+):(?:[^/\s?#]+)@/gi;

// value-shaped secrets (emails, JWTs, tokens) — so it goes through the
// same string scrubber and size bound as every other retained string.
return {
path: scrubString(parsed.pathname, { remainingCharacters: MAX_CONTEXT_STRING_LENGTH }),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a URL contains percent-encoded path data, this call leaves it unsanitized: alice%40example.com and JWTs with %2E bypass the scrubber and enter context.url. Decode the pathname safely before calling scrubString.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/safe-request-context.ts, line 195:

<comment>When a URL contains percent-encoded path data, this call leaves it unsanitized: `alice%40example.com` and JWTs with `%2E` bypass the scrubber and enter `context.url`. Decode the pathname safely before calling `scrubString`.</comment>

<file context>
@@ -156,23 +164,37 @@ function safeHeadersFromEntries(entries: Iterable<readonly [string, string]>): {
+    // value-shaped secrets (emails, JWTs, tokens) — so it goes through the
+    // same string scrubber and size bound as every other retained string.
+    return {
+      path: scrubString(parsed.pathname, { remainingCharacters: MAX_CONTEXT_STRING_LENGTH }),
+      queryParameterCount: [...parsed.searchParams.keys()].length,
+    };
</file context>
Suggested change
path: scrubString(parsed.pathname, { remainingCharacters: MAX_CONTEXT_STRING_LENGTH }),
path: scrubString(
(() => {
try {
return decodeURIComponent(parsed.pathname);
} catch {
return parsed.pathname;
}
})(),
{ remainingCharacters: MAX_CONTEXT_STRING_LENGTH },
),

const cookieName = cookie.split("=", 1)[0]?.trim();
if (cookieName !== "") cookieNames.add(cookieName);
if (cookieName !== "") {
cookieNames.add(scrubString(cookieName, { remainingCharacters: MAX_CONTEXT_STRING_LENGTH }));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When a cookie name percent-encodes PII or JWT delimiters, this call scrubs the escapes instead of the decoded name, so the sensitive name remains in cookies.names. Decode each name safely before applying scrubString.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/safe-request-context.ts, line 168:

<comment>When a cookie name percent-encodes PII or JWT delimiters, this call scrubs the escapes instead of the decoded name, so the sensitive name remains in `cookies.names`. Decode each name safely before applying `scrubString`.</comment>

<file context>
@@ -156,23 +164,37 @@ function safeHeadersFromEntries(entries: Iterable<readonly [string, string]>): {
         const cookieName = cookie.split("=", 1)[0]?.trim();
-        if (cookieName !== "") cookieNames.add(cookieName);
+        if (cookieName !== "") {
+          cookieNames.add(scrubString(cookieName, { remainingCharacters: MAX_CONTEXT_STRING_LENGTH }));
+        }
       }
</file context>
Suggested change
cookieNames.add(scrubString(cookieName, { remainingCharacters: MAX_CONTEXT_STRING_LENGTH }));
cookieNames.add(scrubString(
(() => {
try {
return decodeURIComponent(cookieName);
} catch {
return cookieName;
}
})(),
{ remainingCharacters: MAX_CONTEXT_STRING_LENGTH },
));

}
if (enabled !== true) {
throw new ErrorIngestPolicyConfigError("Error-ingest scrub overrides must be enabled explicitly");
if (typeof selector !== "string" || Buffer.byteLength(selector, "utf8") > MAX_OVERRIDE_KEY_BYTES || !SAFE_OVERRIDE_KEY.test(selector)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: When an error-ingest policy is loaded from environment configuration, this parser rejects the schema's existing {selector: true} overrides because it now requires string values, while the shared schema also rejects the new policy fields. Update the shared schema and migration/serialization contract together; otherwise final scrubbing and the new policy controls cannot be configured through the supported API.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/error-ingest/error-ingest-policy.ts, line 208:

<comment>When an error-ingest policy is loaded from environment configuration, this parser rejects the schema's existing `{selector: true}` overrides because it now requires string values, while the shared schema also rejects the new policy fields. Update the shared schema and migration/serialization contract together; otherwise final scrubbing and the new policy controls cannot be configured through the supported API.</comment>

<file context>
@@ -188,22 +198,22 @@ function parseOverrideKeys(value: unknown, field: "dropKeys" | "urlKeys"): reado
     }
-    if (enabled !== true) {
-      throw new ErrorIngestPolicyConfigError("Error-ingest scrub overrides must be enabled explicitly");
+    if (typeof selector !== "string" || Buffer.byteLength(selector, "utf8") > MAX_OVERRIDE_KEY_BYTES || !SAFE_OVERRIDE_KEY.test(selector)) {
+      throw new ErrorIngestPolicyConfigError("Unsupported error-ingest scrub override key");
     }
</file context>

if (Buffer.byteLength(key, "utf8") > MAX_OVERRIDE_KEY_BYTES || !SAFE_OVERRIDE_KEY.test(key)) {
const keys = new Set<string>();
for (const [ruleId, selector] of entries) {
if (!SAFE_OVERRIDE_RULE_ID.test(ruleId)) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The new override shape in parseOverrideKeys (dotless rule-id keys with string selector values) conflicts with the shared config schema in packages/shared/src/config/schema.ts, which still validates dropKeys/urlKeys as yupRecord(<dotted-selector>, yupBoolean().isTrue()). New configs like { dropEmail: "user.email" } (already used in the new tests) are rejected by schema validation because the value must be boolean true; previously-valid configs like { "user.email": true } now throw ErrorIngestPolicyConfigError at policy parse time. Update the shared schema to accept the new { dotlessId: "selector" } shape (and reject the old boolean shape) so config validation and policy parsing agree.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/error-ingest/error-ingest-policy.ts, line 203:

<comment>The new override shape in `parseOverrideKeys` (dotless rule-id keys with string selector values) conflicts with the shared config schema in `packages/shared/src/config/schema.ts`, which still validates `dropKeys`/`urlKeys` as `yupRecord(<dotted-selector>, yupBoolean().isTrue())`. New configs like `{ dropEmail: "user.email" }` (already used in the new tests) are rejected by schema validation because the value must be boolean `true`; previously-valid configs like `{ "user.email": true }` now throw `ErrorIngestPolicyConfigError` at policy parse time. Update the shared schema to accept the new `{ dotlessId: "selector" }` shape (and reject the old boolean shape) so config validation and policy parsing agree.</comment>

<file context>
@@ -188,22 +198,22 @@ function parseOverrideKeys(value: unknown, field: "dropKeys" | "urlKeys"): reado
-    if (Buffer.byteLength(key, "utf8") > MAX_OVERRIDE_KEY_BYTES || !SAFE_OVERRIDE_KEY.test(key)) {
+  const keys = new Set<string>();
+  for (const [ruleId, selector] of entries) {
+    if (!SAFE_OVERRIDE_RULE_ID.test(ruleId)) {
       // Never echo a configured key: configuration values are not guaranteed
       // to be harmless labels and policy errors must remain payload-free.
</file context>

{ category: "span", reason: "sampling", quantity: 1 },
],
});
// Each OTLP signal counts only its own item types: the filtered log for

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The added comment says "The other five rejections are events/unknown items that no OTLP signal may claim," but only three rejected items are events/unknown (d rate_limited, e rejected, h dropped). The other two rejected items (b filtered log, c filtered span) are the ones the signals claim. "five" should be "three".

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/error-ingest/error-ingest-protocol-adapter.test.ts, line 56:

<comment>The added comment says "The other five rejections are events/unknown items that no OTLP signal may claim," but only three rejected items are events/unknown (d `rate_limited`, e `rejected`, h `dropped`). The other two rejected items (b `filtered` log, c `filtered` span) are the ones the signals claim. "five" should be "three".</comment>

<file context>
@@ -53,18 +53,21 @@ describe("error-ingest protocol adapter", () => {
         { category: "span", reason: "sampling", quantity: 1 },
       ],
     });
+    // Each OTLP signal counts only its own item types: the filtered log for
+    // `logs`, the sampled-out span for `traces`. The other five rejections are
+    // events/unknown items that no OTLP signal may claim.
</file context>

});

it("applies the serialized-size cap to versioned batches only", async () => {
// One byte over the cap once serialized (the JSON envelope around the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: The comment claims the payload is "one byte over the cap once serialized", but { blob: "x".repeat(64000) } serializes to 64,011 bytes — 11 over the cap (the {"blob":" prefix and trailing "} add 11 bytes, and the route's isPlainObjectWithinLimit uses Buffer.byteLength(serialized)). The assertion itself is correct (still over the cap, so it rejects), but the comment's boundary claim is inaccurate and would mislead anyone reasoning about the exact boundary. Correct the figure or make the payload serialize to exactly cap+1.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/app/api/latest/analytics/events/batch/route.test.tsx, line 72:

<comment>The comment claims the payload is "one byte over the cap once serialized", but `{ blob: "x".repeat(64000) }` serializes to 64,011 bytes — 11 over the cap (the `{"blob":"` prefix and trailing `"}` add 11 bytes, and the route's `isPlainObjectWithinLimit` uses `Buffer.byteLength(serialized)`). The assertion itself is correct (still over the cap, so it rejects), but the comment's boundary claim is inaccurate and would mislead anyone reasoning about the exact boundary. Correct the figure or make the payload serialize to exactly cap+1.</comment>

<file context>
@@ -1,6 +1,87 @@
+  });
+
+  it("applies the serialized-size cap to versioned batches only", async () => {
+    // One byte over the cap once serialized (the JSON envelope around the
+    // object adds its own bytes on top of the repeated payload).
+    const oversized = { blob: "x".repeat(CUSTOM_TELEMETRY_MAX_ITEM_DATA_BYTES) };
</file context>
Suggested change
// One byte over the cap once serialized (the JSON envelope around the
// 11 bytes over the cap once serialized (the JSON envelope around the

// Each OTLP signal reports only its own item types. Counting the whole batch
// would let a rejected transaction surface as `rejectedLogRecords` (and vice
// versa) in mixed batches, telling the client the wrong signal failed.
const counts = summarizeErrorIngestOutcomes(items.filter((item) => isSignalItemType(item.itemType, signal))).counts;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: In otlpPartialSuccess the per-signal counts are obtained via summarizeErrorIngestOutcomes(...), which computes a batch status/reason (an extra loop over outcomes.slice(1)) that is discarded — only .counts is used. Extract a countErrorIngestOutcomes helper that just builds the counts in one pass and use it here (and in createErrorIngestProtocolProjection where the summary status is actually needed).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/lib/error-ingest/error-ingest-protocol-adapter.ts, line 533:

<comment>In `otlpPartialSuccess` the per-signal counts are obtained via `summarizeErrorIngestOutcomes(...)`, which computes a batch `status`/`reason` (an extra loop over `outcomes.slice(1)`) that is discarded — only `.counts` is used. Extract a `countErrorIngestOutcomes` helper that just builds the counts in one pass and use it here (and in `createErrorIngestProtocolProjection` where the summary status is actually needed).</comment>

<file context>
@@ -531,11 +516,21 @@ function boundedErrorMessage(counts: ErrorIngestBatchCounts, maxBytes: number):
+  // Each OTLP signal reports only its own item types. Counting the whole batch
+  // would let a rejected transaction surface as `rejectedLogRecords` (and vice
+  // versa) in mixed batches, telling the client the wrong signal failed.
+  const counts = summarizeErrorIngestOutcomes(items.filter((item) => isSignalItemType(item.itemType, signal))).counts;
   const rejectedItems = rejectedItemCount(counts);
   const errorMessage = boundedErrorMessage(counts, maxErrorMessageBytes);
</file context>

// initial data must obey the same bound addBreadcrumb enforces, otherwise
// createErrorScope(initial) becomes a bypass that emits arbitrarily large
// error payloads. Newest breadcrumbs win, matching addBreadcrumb.
...data.breadcrumbs === undefined ? {} : { breadcrumbs: data.breadcrumbs.slice(-MAX_BREADCRUMBS).map((breadcrumb) => ({ ...breadcrumb, ...breadcrumb.data === undefined ? {} : { data: { ...breadcrumb.data } } })) },

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: This PR caps initial breadcrumbs in copyScopeData to close the createErrorScope(initial) bypass, but initial eventProcessors are still copied unbounded ([...data.eventProcessors], no slice). A scope seeded with more than MAX_EVENT_PROCESSORS processors is therefore not capped like breadcrumbs; the new per-source check in processErrorEvent then drops the entire capture. Apply the same bound here (eventProcessors.slice(0, MAX_EVENT_PROCESSORS)) so oversized initial data is trimmed to the newest MAX_EVENT_PROCESSORS processors instead of failing the whole event.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/template/src/lib/hexclave-app/apps/implementations/error-scope.ts, line 37:

<comment>This PR caps initial `breadcrumbs` in `copyScopeData` to close the `createErrorScope(initial)` bypass, but initial `eventProcessors` are still copied unbounded (`[...data.eventProcessors]`, no slice). A scope seeded with more than `MAX_EVENT_PROCESSORS` processors is therefore not capped like breadcrumbs; the new per-source check in `processErrorEvent` then drops the entire capture. Apply the same bound here (`eventProcessors.slice(0, MAX_EVENT_PROCESSORS)`) so oversized initial data is trimmed to the newest `MAX_EVENT_PROCESSORS` processors instead of failing the whole event.</comment>

<file context>
@@ -30,11 +30,15 @@ function copyScopeData(data: ErrorScopeData | undefined): ErrorScopeData {
+    // initial data must obey the same bound addBreadcrumb enforces, otherwise
+    // createErrorScope(initial) becomes a bypass that emits arbitrarily large
+    // error payloads. Newest breadcrumbs win, matching addBreadcrumb.
+    ...data.breadcrumbs === undefined ? {} : { breadcrumbs: data.breadcrumbs.slice(-MAX_BREADCRUMBS).map((breadcrumb) => ({ ...breadcrumb, ...breadcrumb.data === undefined ? {} : { data: { ...breadcrumb.data } } })) },
     ...data.level === undefined ? {} : { level: data.level },
     ...data.fingerprint === undefined ? {} : { fingerprint: [...data.fingerprint] },
</file context>

// Bounded at the single choke point every capture path funnels through, so
// neither a deep automatic cause chain nor an adapter-supplied chain can
// push the event past the shared item-data budget.
const exceptionValues = boundExceptionValues(options.exceptionValues ?? [exceptionValueFromNormalized(normalized)]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3: boundExceptionValues sizes each entry from the value BEFORE the primary's mechanism is added in buildErrorEventDataFromNormalized, and boundExceptionValue does not truncate adapter-supplied fields other than type/value/stacktrace. The shipped exception.values can therefore exceed the 32KB budget that the new test asserts as an upper bound. Count the final primary mechanism (and any remaining value fields) in the byte accounting so the budget is a real invariant of the serialized array.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/template/src/lib/hexclave-app/apps/implementations/error-capture.ts, line 356:

<comment>`boundExceptionValues` sizes each entry from the value BEFORE the primary's `mechanism` is added in `buildErrorEventDataFromNormalized`, and `boundExceptionValue` does not truncate adapter-supplied fields other than type/value/stacktrace. The shipped `exception.values` can therefore exceed the 32KB budget that the new test asserts as an upper bound. Count the final primary mechanism (and any remaining value fields) in the byte accounting so the budget is a real invariant of the serialized array.</comment>

<file context>
@@ -280,19 +336,24 @@ export function buildErrorEventData(error: unknown, options: BuildErrorEventData
+  // Bounded at the single choke point every capture path funnels through, so
+  // neither a deep automatic cause chain nor an adapter-supplied chain can
+  // push the event past the shared item-data budget.
+  const exceptionValues = boundExceptionValues(options.exceptionValues ?? [exceptionValueFromNormalized(normalized)]);
   const primaryExceptionIndex = exceptionValues.length - 1;
   const exceptions = exceptionValues.map((exception, index) => index === primaryExceptionIndex
</file context>

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant