diff --git a/packages/core/analytics/benign-exceptions.test.ts b/packages/core/analytics/benign-exceptions.test.ts new file mode 100644 index 000000000..75a4ce2a9 --- /dev/null +++ b/packages/core/analytics/benign-exceptions.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { isBenignException } from "./benign-exceptions"; + +describe("isBenignException", () => { + it("drops ResizeObserver loop errors via $exception_list value", () => { + expect( + isBenignException({ + $exception_list: [ + { + type: "Error", + value: "ResizeObserver loop completed with undelivered notifications.", + }, + ], + }), + ).toBe(true); + }); + + it("drops the older 'loop limit exceeded' phrasing", () => { + expect( + isBenignException({ + $exception_list: [ + { type: "Error", value: "ResizeObserver loop limit exceeded" }, + ], + }), + ).toBe(true); + }); + + it("drops when the signal is on the top-level $exception_message", () => { + expect( + isBenignException({ + $exception_message: "ResizeObserver loop limit exceeded", + }), + ).toBe(true); + }); + + it("matches case-insensitively", () => { + expect( + isBenignException({ $exception_message: "resizeobserver LOOP limit exceeded" }), + ).toBe(true); + }); + + it("keeps real errors", () => { + expect( + isBenignException({ + $exception_list: [ + { + type: "TypeError", + value: "Cannot read properties of undefined (reading 'split')", + }, + ], + }), + ).toBe(false); + }); + + it("does not match an unrelated mention of ResizeObserver", () => { + // Only the benign "loop" phrasing is silenced; a genuine bug in + // ResizeObserver usage must still be reported. + expect( + isBenignException({ + $exception_message: "ResizeObserver is not defined", + }), + ).toBe(false); + }); + + it("fails open on missing or malformed properties", () => { + expect(isBenignException(undefined)).toBe(false); + expect(isBenignException({})).toBe(false); + expect(isBenignException({ $exception_list: "not-an-array" })).toBe(false); + expect(isBenignException({ $exception_list: [null, 42, {}] })).toBe(false); + expect(isBenignException({ $exception_message: 123 })).toBe(false); + }); +}); diff --git a/packages/core/analytics/benign-exceptions.ts b/packages/core/analytics/benign-exceptions.ts new file mode 100644 index 000000000..247b4b054 --- /dev/null +++ b/packages/core/analytics/benign-exceptions.ts @@ -0,0 +1,52 @@ +// Known-benign browser exceptions that are pure noise in `$exception` +// telemetry. These are dropped ENTIRELY in `before_send` (not merely deduped by +// exception-dedupe.ts) — they carry no actionable signal, the browser +// self-recovers, and at scale they dominate the error stream, drowning real +// failures and burning the billed event budget. +// +// ResizeObserver "loop ..." errors are the canonical case: the spec fires them +// when observation callbacks don't settle within a single animation frame. The +// browser resumes delivery on the next frame, so nothing actually breaks. Every +// app that uses ResizeObserver (directly or via a UI library) emits them. The +// CSSWG explicitly considers them benign — see w3c/csswg-drafts#5023. Across +// Chrome versions the message is either "ResizeObserver loop limit exceeded" +// (older) or "ResizeObserver loop completed with undelivered notifications" +// (newer); both contain "ResizeObserver loop". +// +// The bar for adding a pattern here is high: it must be a benign, +// self-recovering error with no actionable signal. A real bug must never be +// silenced — when unsure, leave it to the dedupe fuse, which only caps repeats. + +const BENIGN_MESSAGE_PATTERNS: RegExp[] = [/ResizeObserver loop/i]; + +/** + * Whether this `$exception` event is known-benign browser noise that should be + * dropped entirely. Reads the message from the (pre-redaction) event + * properties — the matched messages carry no PII, so reading them raw is safe, + * and matching before redaction avoids any chance of a scrub mangling the + * signal. Never throws: any unexpected shape returns `false` (keep the event), + * the fail-open direction `before_send` requires. + */ +export function isBenignException( + properties: Record | undefined, +): boolean { + if (!properties || typeof properties !== "object") return false; + + const messages: unknown[] = [properties.$exception_message]; + const list = properties.$exception_list; + if (Array.isArray(list)) { + for (const entry of list) { + if (entry && typeof entry === "object" && "value" in entry) { + messages.push((entry as { value: unknown }).value); + } + } + } + + for (const message of messages) { + if (typeof message !== "string") continue; + for (const pattern of BENIGN_MESSAGE_PATTERNS) { + if (pattern.test(message)) return true; + } + } + return false; +} diff --git a/packages/core/analytics/index.ts b/packages/core/analytics/index.ts index 1d1cc6c2e..b6901fe73 100644 --- a/packages/core/analytics/index.ts +++ b/packages/core/analytics/index.ts @@ -15,6 +15,7 @@ import posthog from "posthog-js"; import { redactExceptionProperties } from "./redact-exception"; import { shouldDropException } from "./exception-dedupe"; +import { isBenignException } from "./benign-exceptions"; export const EVENT_SCHEMA_VERSION = 2; @@ -166,6 +167,11 @@ export function initAnalytics(config: AnalyticsConfig | null | undefined): boole capture_exceptions: true, before_send: (event) => { if (event && event.event === "$exception") { + // Drop known-benign browser noise (e.g. ResizeObserver loop) entirely + // — checked on the raw message before redaction. These dominate the + // stream and carry no signal, so they skip both redaction and the + // dedupe fuse. See benign-exceptions.ts. + if (isBenignException(event.properties)) return null; redactExceptionProperties(event.properties); if (shouldDropException(event.properties)) return null; }