diff --git a/docs/SENTRY.md b/docs/SENTRY.md index e4485a5..cdd3d03 100644 --- a/docs/SENTRY.md +++ b/docs/SENTRY.md @@ -146,6 +146,45 @@ Source maps do not change this posture: they reveal *code structure* (already shipped publicly in minified form), never runtime data. Auth token and upload traffic stay inside the build — nothing secret reaches the browser bundle. +## Foreign-noise filters + +`sanitizeSentryEvent` also returns `null` for a small list of confirmed +injected/external noise signatures (`KNOWN_FOREIGN_NOISE` in `lib/sentry.ts`). +Every predicate is a conjunction of an exact error signature AND +foreign/vendor evidence — never a broad error class — and each has paired +unit tests proving the observed event drops while a similar legitimate +error retains. A global veto also applies: any `/_next/`, `webpack://` or +`gtm.js` frame anywhere in the event's exception chain keeps it +reporting, so a genuine app error can never be swallowed by a noise rule. + +| Predicate | Drops | Requires | +|---|---|---| +| `isInjectedMediaFilterError` | extension media-filter errors | `DataCloneError` postMessage/`HTMLIFrameElement`/`could not be cloned` message + `mediafilter` frame | +| `isClarityIcuError` | Clarity `Internal error. Icu error.` | `RangeError` + exact message + `clarity.js` frame + `Intl.DateTimeFormat` frame | +| `isExtensionSendMessageError` | extension `runtime.sendMessage` failures | exact `Invalid call to runtime.sendMessage(). Tab not found` message + `auto.browser.global_handlers.onunhandledrejection` mechanism | +| `isTranslateStackOverflow` | Google Translate stack overflow | `RangeError` + "Maximum call stack size exceeded" + `translate_http`/`el_main` frame (`translate.goog` is only the proxy host, not evidence) | +| `isInjectedCookiebotError` | extension collision with Cookiebot | `TypeError: Illegal invocation` + `inject_content` frame + `cc.js`/`uc.js` frame | +| `isNativeBridgeProbeError` | injected `webkit.messageHandlers` probes | `webkit.messageHandlers` message + foreign `app:///` frame + no first-party frame | +| `isJsloaderTrackingScriptError` | injected `tracking_script.js` jsloader failure | `CustomError` + exact jsloader/client.js prefix + `tracking_script.js` frame | + +Deliberately **kept reporting** (retain tests pin these): + +- `SyntaxError: Invalid or unexpected token` from + `app:///0aa4d9c5efab527d/script.js` — a real Vercel Web Analytics delivery + anomaly we own (#176). +- `ReferenceError: $ is not defined` inside `app:///gtm.js` — our own GTM + container (`GTM-5PFMJFN`), so the tag is ours to fix, not suppress. + Production breadcrumb: `GTM PTag v1.4; tagId: 2613447705545` — `__pntr` + identifies the Pinterest Tag gallery template, so this is a Pinterest tag + configured in the GTM dashboard whose code appears to assume jQuery + (which the site intentionally does not ship). The container is not + versioned in this repo. Owner checklist: in the GTM container, search + tags/templates for the Pinterest template or tag ID `2613447705545`, + inspect its trigger and custom code for `$(`/`jQuery(`, confirm whether + Pinterest tracking is a current business requirement, then either remove + the tag, update the template, or rewrite it with native DOM APIs — + publish only after consent validation. Do not add jQuery or a fake `$`. + ## CSP `connect-src` gains exactly one origin: the `https:` origin parsed out of diff --git a/lib/sentry.ts b/lib/sentry.ts index b8b42dd..6488b97 100644 --- a/lib/sentry.ts +++ b/lib/sentry.ts @@ -143,6 +143,147 @@ export function isExtensionSendMessageError(event: ErrorEvent): boolean { return false; } +/** + * Google Translate stack overflow: machine-translated pageviews (e.g. + * `www-seasaba-com.translate.goog`) run Google's `translate_http` / + * `el_main` scripts, whose DOM rewriting can recurse until the browser + * throws `RangeError: Maximum call stack size exceeded`. The filter + * requires the overflow message AND a Translate-machinery stack signature + * — an app-side stack overflow, a different RangeError inside Translate + * code, or an overflow in any other vendor still reports. `translate.goog` + * is deliberately not evidence: it is the proxy host on every frame of a + * translated pageview, including our own `/_next/` code. + */ +export function isTranslateStackOverflow(event: ErrorEvent): boolean { + for (const exception of event.exception?.values ?? []) { + const isStackOverflow = + exception.type === "RangeError" && + typeof exception.value === "string" && + exception.value.includes("Maximum call stack size exceeded"); + if (!isStackOverflow) continue; + + if ( + stackContains(exception, "translate_http") || + stackContains(exception, "el_main") + ) { + return true; + } + } + return false; +} + +/** + * Injected content-script collision with Cookiebot: `TypeError: Illegal + * invocation` thrown inside `app:///dist/inject_content.js` while touching + * Cookiebot's `cc.js`/`uc.js`. `inject_content.js` is a known + * extension/content-script artifact that exists nowhere in our bundle. + * Both foreign frames are required: a Cookiebot defect without the + * injected frame (and any app-side Illegal invocation) still reports. + */ +export function isInjectedCookiebotError(event: ErrorEvent): boolean { + for (const exception of event.exception?.values ?? []) { + const isIllegalInvocation = + exception.type === "TypeError" && + typeof exception.value === "string" && + exception.value.includes("Illegal invocation"); + if (!isIllegalInvocation) continue; + + if ( + stackContains(exception, "inject_content") && + (stackContains(exception, "cc.js") || stackContains(exception, "uc.js")) + ) { + return true; + } + } + return false; +} + +/** + * Opaque native-bridge probe: `window.webkit.messageHandlers` referenced + * from a foreign `app:///…` script with no first-party frame in the stack. + * The site has no WKWebView bridge; injected scripts probing for one throw + * outside their expected native context. The conjunction keeps this + * narrow: the same message thrown by first-party code (a real future + * native-wrapper integration would run from `/_next/` chunks) or with no + * foreign frame evidence still reports. `gtm.js` frames count as + * first-party presence — the container is ours, so a probe touched by GTM + * tag code keeps reporting like any other own-stack failure. + */ +export function isNativeBridgeProbeError(event: ErrorEvent): boolean { + for (const exception of event.exception?.values ?? []) { + const isBridgeProbe = + typeof exception.value === "string" && + exception.value.includes("webkit.messageHandlers"); + if (!isBridgeProbe) continue; + + const frames = exception.stacktrace?.frames ?? []; + const hasForeignScriptFrame = frames.some( + (frame) => + typeof frame.filename === "string" && + stripUrlSensitiveParts(frame.filename).startsWith("app:///") && + !isFirstPartyFrameFilename(frame.filename) + ); + const hasFirstPartyFrame = frames.some( + (frame) => + typeof frame.filename === "string" && + isFirstPartyFrameFilename(frame.filename) + ); + if (hasForeignScriptFrame && !hasFirstPartyFrame) return true; + } + return false; +} + +/** + * Google jsloader failure inside an injected `tracking_script.js`: the + * observed event is `CustomError: Jsloader error (code #0): Error while + * loading script https://apis.google.com/js/client.js` with a stack + * entirely in `app:///tracking_script.js` — a foreign tracker script, not + * a repo or deployed-bundle file. The exact error type + exact jsloader + * prefix + the injected frame are all required, so a jsloader failure for + * any other script, the same message from first-party code, or a + * different CustomError still reports. + */ +export function isJsloaderTrackingScriptError(event: ErrorEvent): boolean { + for (const exception of event.exception?.values ?? []) { + const isJsloaderFailure = + exception.type === "CustomError" && + typeof exception.value === "string" && + exception.value.startsWith( + "Jsloader error (code #0): Error while loading script https://apis.google.com/js/client.js" + ); + if (!isJsloaderFailure) continue; + + if (stackContains(exception, "tracking_script.js")) return true; + } + return false; +} + +/** + * First-party frame evidence: our compiled chunks (`/_next/` on any host — + * including `translate.goog` proxied pageviews), `webpack://` module paths, + * and the GTM runtime (`gtm.js`; our container is ours to fix, and the URL + * always carries `?id=…`, so compare the sanitized filename). + */ +function isFirstPartyFrameFilename(filename: string): boolean { + const normalized = stripUrlSensitiveParts(filename); + return ( + normalized.includes("/_next/") || + normalized.startsWith("webpack://") || + normalized.endsWith("/gtm.js") + ); +} + +/** True when any exception value's stack carries a first-party frame. */ +function hasFirstPartyStackFrame(event: ErrorEvent): boolean { + return (event.exception?.values ?? []).some((exception) => + (exception.stacktrace?.frames ?? []).some( + (frame) => + typeof frame.filename === "string" && + isFirstPartyFrameFilename(frame.filename) + ) + ); +} + /** True when any stack frame's filename or function name contains `needle`. */ function stackContains( exception: { @@ -169,6 +310,10 @@ const KNOWN_FOREIGN_NOISE: ReadonlyArray<(event: ErrorEvent) => boolean> = [ isInjectedMediaFilterError, isClarityIcuError, isExtensionSendMessageError, + isTranslateStackOverflow, + isInjectedCookiebotError, + isNativeBridgeProbeError, + isJsloaderTrackingScriptError, ]; /** @@ -189,8 +334,14 @@ export function sanitizeSentryEvent(event: ErrorEvent): ErrorEvent | null { // Drop events proven to originate from foreign injected/vendor code (see // KNOWN_FOREIGN_NOISE — #174 mediafilter, #176 Clarity ICU, #176 // extension runtime.sendMessage). Each rule requires its full signature; - // anything else falls through untouched. - if (KNOWN_FOREIGN_NOISE.some((isForeignNoise) => isForeignNoise(event))) { + // anything else falls through untouched. The first-party veto comes + // first: an event whose exception chain still carries our own frames — + // a real app error alongside matching noise, or our code running inside + // a translated pageview — is never swallowed by a noise rule. + if ( + !hasFirstPartyStackFrame(event) && + KNOWN_FOREIGN_NOISE.some((isForeignNoise) => isForeignNoise(event)) + ) { return null; } diff --git a/tests/unit/sentry.test.ts b/tests/unit/sentry.test.ts index 19cc705..197a071 100644 --- a/tests/unit/sentry.test.ts +++ b/tests/unit/sentry.test.ts @@ -5,8 +5,12 @@ import type { ErrorEvent } from "@sentry/nextjs"; import { isClarityIcuError, isExtensionSendMessageError, + isInjectedCookiebotError, isInjectedMediaFilterError, + isJsloaderTrackingScriptError, + isNativeBridgeProbeError, isSentryActive, + isTranslateStackOverflow, sanitizeSentryEvent, sentryDeploymentEnv, sentryDsn, @@ -390,6 +394,370 @@ describe("isExtensionSendMessageError (#176)", () => { }); }); +describe("isTranslateStackOverflow", () => { + function overflowEvent( + frames: object[], + value = "Maximum call stack size exceeded", + type = "RangeError" + ): ErrorEvent { + return eventWith({ + exception: { + values: [{ type, value, stacktrace: { frames } }], + }, + }); + } + + it("drops the translate.goog stack-overflow signature observed in production", () => { + // Mirrors the /plan-your-trip event: the whole stack is Google + // Translate machinery running inside www-seasaba-com.translate.goog. + const event = overflowEvent([ + { filename: "translate_http/.../el_main.js", function: "ka" }, + { filename: "translate_http/.../el_main.js", function: "ma" }, + { + filename: "https://www-seasaba-com.translate.goog/plan-your-trip", + }, + ]); + expect(isTranslateStackOverflow(event)).toBe(true); + expect(sanitizeSentryEvent(event)).toBeNull(); + }); + + it("drops the recursion variant whose only evidence is translate_http frames", () => { + // Mirrors the /about event: same recursion pattern, Translate-only stack. + const event = overflowEvent([ + { filename: "translate_http/js/element/main/el_main.js" }, + { filename: "translate_http/js/element/main/el_main.js" }, + ]); + expect(isTranslateStackOverflow(event)).toBe(true); + }); + + it("retains an app-side RangeError: Maximum call stack size exceeded", () => { + const event = overflowEvent([ + { filename: "https://www.seasaba.com/_next/static/chunks/app.js" }, + { filename: "webpack://seasaba-web/components/dive-log-client.tsx" }, + ]); + expect(isTranslateStackOverflow(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a stack overflow with unrelated vendor frames", () => { + const event = overflowEvent([{ filename: "0.8.70/clarity.js" }]); + expect(isTranslateStackOverflow(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a different RangeError even with Translate frames", () => { + const event = overflowEvent( + [{ filename: "translate_http/js/element/main/el_main.js" }], + "Internal error. Icu error." + ); + expect(isTranslateStackOverflow(event)).toBe(false); + }); + + it("retains a stack overflow reported as a different exception type", () => { + const event = overflowEvent( + [{ filename: "translate_http/js/element/main/el_main.js" }], + "Maximum call stack size exceeded", + "InternalError" + ); + expect(isTranslateStackOverflow(event)).toBe(false); + }); + + it("retains an app overflow on a translate.goog-proxied pageview", () => { + // translate.goog is the proxy host on EVERY frame of a translated + // pageview — including our own chunks — so it is not machinery + // evidence on its own. + const event = overflowEvent([ + { + filename: + "https://www-seasaba-com.translate.goog/_next/static/chunks/app.js", + }, + ]); + expect(isTranslateStackOverflow(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a mixed stack: Translate machinery plus first-party code", () => { + // The predicate's conjunction matches, but the first-party veto in + // sanitizeSentryEvent keeps any event whose stack still runs our code. + const event = overflowEvent([ + { filename: "translate_http/js/element/main/el_main.js" }, + { + filename: + "https://www-seasaba-com.translate.goog/_next/static/chunks/app.js", + }, + ]); + expect(isTranslateStackOverflow(event)).toBe(true); + expect(sanitized(event)).toBeDefined(); + }); +}); + +describe("isInjectedCookiebotError", () => { + function illegalInvocationEvent(frames: object[], type = "TypeError"): ErrorEvent { + return eventWith({ + exception: { + values: [ + { + type, + value: "Illegal invocation", + stacktrace: { frames }, + }, + ], + }, + }); + } + + it("drops the injected-content + Cookiebot signature observed in production", () => { + const event = illegalInvocationEvent([ + { filename: "app:///dist/inject_content.js" }, + { filename: "https://consentcdn.cookiebot.com/uc.js" }, + ]); + expect(isInjectedCookiebotError(event)).toBe(true); + expect(sanitizeSentryEvent(event)).toBeNull(); + }); + + it("drops the same signature against the Cookiebot cc.js frame", () => { + const event = illegalInvocationEvent([ + { filename: "app:///dist/inject_content.js" }, + { filename: "https://consent.cookiebot.com/cc.js" }, + ]); + expect(isInjectedCookiebotError(event)).toBe(true); + }); + + it("retains an app-side Illegal invocation", () => { + const event = illegalInvocationEvent([ + { filename: "https://www.seasaba.com/_next/static/chunks/app.js" }, + { filename: "webpack://seasaba-web/lib/analytics.ts" }, + ]); + expect(isInjectedCookiebotError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a Cookiebot error without injected-content evidence", () => { + const event = illegalInvocationEvent([ + { filename: "https://consentcdn.cookiebot.com/uc.js" }, + { filename: "https://www.seasaba.com/_next/static/chunks/app.js" }, + ]); + expect(isInjectedCookiebotError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains an injected-script error that does not touch Cookiebot", () => { + const event = illegalInvocationEvent([ + { filename: "app:///dist/inject_content.js" }, + { filename: "app:///gtm.js" }, + ]); + expect(isInjectedCookiebotError(event)).toBe(false); + }); +}); + +describe("isNativeBridgeProbeError", () => { + const bridgeValue = + "Cannot read properties of undefined (reading 'webkit.messageHandlers')"; + + function bridgeEvent(frames: object[], value = bridgeValue): ErrorEvent { + return eventWith({ + exception: { + values: [{ type: "TypeError", value, stacktrace: { frames } }], + }, + }); + } + + it("drops the opaque injected-bridge signature observed in production", () => { + const event = eventWith({ + exception: { + values: [ + { + type: "TypeError", + value: "window.webkit.messageHandlers is undefined", + stacktrace: { + frames: [{ filename: "app:///script.js" }], + }, + }, + ], + }, + }); + expect(isNativeBridgeProbeError(event)).toBe(true); + expect(sanitizeSentryEvent(event)).toBeNull(); + }); + + it("retains the same probe thrown by first-party bundle code", () => { + // A real future native-wrapper integration runs from /_next/ chunks — + // those errors must keep reporting. + const event = bridgeEvent([ + { filename: "app:///script.js" }, + { filename: "app:///_next/static/chunks/app.js" }, + ]); + expect(isNativeBridgeProbeError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains the same message with no foreign-frame evidence", () => { + const event = bridgeEvent([ + { filename: "https://www.seasaba.com/_next/static/chunks/main-app.js" }, + ]); + expect(isNativeBridgeProbeError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a probe thrown inside gtm.js — our container is ours to fix", () => { + const event = bridgeEvent([ + { filename: "app:///script.js" }, + { filename: "app:///gtm.js" }, + ]); + expect(isNativeBridgeProbeError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains the probe when the gtm.js frame carries its ?id= query string", () => { + const event = bridgeEvent([ + { filename: "app:///script.js" }, + { filename: "app:///gtm.js?id=GTM-5PFMJFN" }, + ]); + expect(isNativeBridgeProbeError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains the probe thrown by a webpack module path", () => { + const event = bridgeEvent([ + { filename: "app:///script.js" }, + { filename: "webpack://seasaba-web/lib/native-bridge.ts" }, + ]); + expect(isNativeBridgeProbeError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a different error inside an opaque app script", () => { + const event = bridgeEvent( + [{ filename: "app:///script.js" }], + "Cannot read properties of undefined (reading 'foo')" + ); + expect(isNativeBridgeProbeError(event)).toBe(false); + }); +}); + +describe("isJsloaderTrackingScriptError", () => { + const jsloaderValue = + "Jsloader error (code #0): Error while loading script https://apis.google.com/js/client.js?onload=loadScripts"; + + function jsloaderEvent( + frames: object[], + value = jsloaderValue, + type = "CustomError" + ): ErrorEvent { + return eventWith({ + exception: { + values: [{ type, value, stacktrace: { frames } }], + }, + }); + } + + it("drops the jsloader + tracking_script signature observed in production", () => { + const event = jsloaderEvent([ + { filename: "app:///tracking_script.js" }, + { filename: "app:///tracking_script.js" }, + ]); + expect(isJsloaderTrackingScriptError(event)).toBe(true); + expect(sanitizeSentryEvent(event)).toBeNull(); + }); + + it("retains a jsloader failure for a different Google script", () => { + const event = jsloaderEvent( + [{ filename: "app:///tracking_script.js" }], + "Jsloader error (code #0): Error while loading script https://apis.google.com/js/plusone.js" + ); + expect(isJsloaderTrackingScriptError(event)).toBe(false); + }); + + it("retains the same jsloader message without the injected frame", () => { + const event = jsloaderEvent([ + { filename: "https://www.seasaba.com/_next/static/chunks/app.js" }, + ]); + expect(isJsloaderTrackingScriptError(event)).toBe(false); + expect(sanitized(event)).toBeDefined(); + }); + + it("retains a different CustomError inside tracking_script.js", () => { + const event = jsloaderEvent( + [{ filename: "app:///tracking_script.js" }], + "Jsloader error (code #1): Timeout" + ); + expect(isJsloaderTrackingScriptError(event)).toBe(false); + }); + + it("retains the same signature under a different exception type", () => { + const event = jsloaderEvent( + [{ filename: "app:///tracking_script.js" }], + jsloaderValue, + "Error" + ); + expect(isJsloaderTrackingScriptError(event)).toBe(false); + }); +}); + +describe("first-party stack veto", () => { + it("retains a multi-exception event whose other exception runs our code", () => { + // A chained exception: the inner one matches a noise signature, but the + // outer one is a genuine app error — the event must not be swallowed. + const event = eventWith({ + exception: { + values: [ + { + type: "CustomError", + value: + "Jsloader error (code #0): Error while loading script https://apis.google.com/js/client.js", + stacktrace: { + frames: [{ filename: "app:///tracking_script.js" }], + }, + }, + { + type: "TypeError", + value: "Cannot read properties of undefined (reading 'dive')", + stacktrace: { + frames: [ + { + filename: + "https://www.seasaba.com/_next/static/chunks/app.js", + }, + { filename: "webpack://seasaba-web/lib/dive-log.ts" }, + ], + }, + }, + ], + }, + }); + expect(isJsloaderTrackingScriptError(event)).toBe(true); + expect(sanitized(event)).toBeDefined(); + }); +}); + +describe("GTM container tag errors stay reporting", () => { + it("retains the `$ is not defined` ReferenceError thrown inside gtm.js", () => { + // The event fires inside our own GTM-5PFMJFN container — tag/config is + // ours to fix, so it must never be suppressed. Breadcrumb context in + // production: "GTM PTag v1.4; tagId: 2613447705545" (Pinterest Tag). + const event = eventWith({ + exception: { + values: [ + { + type: "ReferenceError", + value: "$ is not defined", + stacktrace: { + frames: [ + { filename: "app:///gtm.js" }, + { filename: "app:///gtm.js" }, + ], + }, + }, + ], + }, + breadcrumbs: [ + { category: "console", message: "GTM PTag v1.4; tagId: 2613447705545" }, + ], + }); + expect(sanitized(event)).toBeDefined(); + }); +}); + describe("opaque same-origin script SyntaxError (#176)", () => { it("retains a SyntaxError from the deployed Vercel Analytics script path", () => { // Event eeefa934: `SyntaxError: Invalid or unexpected token` in