feat(observability): harden error ingest - #1972
Conversation
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>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
| 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"; | ||
|
|
There was a problem hiding this comment.
| 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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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>
| 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 }), |
There was a problem hiding this comment.
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>
| 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 })); |
There was a problem hiding this comment.
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>
| 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)) { |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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>
| // 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; |
There was a problem hiding this comment.
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 } } })) }, |
There was a problem hiding this comment.
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)]); |
There was a problem hiding this comment.
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>
Summary
Stack
Base: OTel runtime and SDK
Next: source maps, releases, and issues
Test plan
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.
ErrorIngestEnvelopeError/ErrorIngestClientReportParseErrorto 400; let other faults surface as generic 500s; redact secret-bearing text at parse time.summarizeErrorIngestOutcomes; protocol adapter reports per-signal OTLP rejections (logs vs. traces) instead of a flat total.ErrorAttachmentNotFoundError,ErrorAttachmentConflictError); enforcevalidateErrorAttachmentUpload; cap storage key to 1024 bytes and repository reads toMAX_ERROR_ATTACHMENTS_PER_EVENT; guard routes withassertObservabilityEnabled.urlKeys.trace_idandspan_idexist; retainparent_span_idfor distributed roots.@/lib/otlp/*; logs surface message inbodyJSON, not amessagecolumn.addBreadcrumb, trim deep cause chains to the exception byte budget, avoid extra attachment byte copies; documentfingerprintas the grouping token.Migration
@/lib/telemetry-ingestwith@/lib/error-ingest; update OTLP imports to@/lib/otlp/*.createErrorIngestBatchOutcome, switch tosummarizeErrorIngestOutcomes.body.value.observability.errorIngest.finalScrub.dropKeys/urlKeysnow map rule ids to selectors (e.g.,dropEmail: "user.email"). Rule ids must be dotless; unsafe selectors are rejected.Written for commit 6bb09ae. Summary will update on new commits.