feat(analytics): drop benign ResizeObserver exceptions from telemetry

ResizeObserver "loop ..." errors are the dominant `$exception` bucket
(~31k events / ~1.5k users). They are a benign, self-recovering browser
quirk with no actionable signal and otherwise drown real failures and burn
the event budget. Drop them entirely in before_send (ahead of redaction
and the dedupe fuse, which only caps repeats). The match is narrow — only
the benign "loop" phrasing — so a genuine ResizeObserver bug still reports.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
Naiyuan Qing
2026-06-18 17:02:08 +08:00
parent 7937599d0d
commit fdda73e671
3 changed files with 130 additions and 0 deletions

View File

@@ -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);
});
});

View File

@@ -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<string, unknown> | 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;
}

View File

@@ -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;
}