diff --git a/CHANGELOG.md b/CHANGELOG.md index 168aef80..54ea5d49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Redirect the 13 AgentEye pages the upstream syncs deleted (`/agenteye/deployment`, `/agenteye/kubernetes-deployment`, `/agenteye/troubleshooting`, …) to a live page instead of hard-404ing. (#556) ### Fixes +- Report only our own dashboard failures, not browser extensions'. `GlobalErrorListeners` registers page-global `error` / `unhandledrejection` handlers, and browser extensions inject content scripts into the same page and share the same `window` — so their failures reached our listeners and went to PostHog stamped `$lib: failproofai-web`, as though the dashboard had thrown them. Observed live: MetaMask's "Failed to connect to MetaMask" (`error_name: "i"`, its minified class) arriving as a failproofai `unhandled_rejection` on `/policies`. Extensions are user-installed and open-ended, so the noise was unbounded, depended on which extensions a user happened to run rather than on our code, and would eventually have drowned the real signal these listeners exist to catch. Both handlers now attribute the error before reporting (new `lib/error-origin.ts`) and report it only when it traces back to our own origin. Attribution is positive rather than a denylist of known extensions, so an unrecognised extension is filtered by default; the match is on the shared `-extension://` suffix, so a new browser's scheme needs no code change. Unattributable errors (cross-origin `"Script error."`, rejections of non-Error values) are dropped too — there is nothing in them to debug. React render errors are unaffected: the error boundaries report `client_error` directly, and React only invokes those for errors thrown inside our own tree. (#565) - Delete translated pages whose English source no longer exists, and stop them coming back: `translate-docs` gained a `--prune` mode that runs by default on every translation pass (`--no-prune` opts out) and as an explicit step in the `consolidate` job — that job re-checks-out `main` and *overlays* the artifacts, so a prune done only in the per-language jobs would be undone. Translation only ever moved forward, so the 11 pages the AgentEye syncs removed upstream left 154 orphans (11 × 14 locales) that `--update-nav` dropped from the sidebar but Mintlify still served and indexed — non-English readers could land on docs for a deleted feature with no way out. A repo invariant test now fails if any translation outlives its English source. (#556) - Move the docs auto-translation daily cron from 06:00 UTC to 11:05 AM IST (05:35 UTC, encoded as `35 5 * * *` since GitHub Actions cron is always UTC). (#553) diff --git a/__tests__/components/global-error-listeners.test.tsx b/__tests__/components/global-error-listeners.test.tsx new file mode 100644 index 00000000..0b981eee --- /dev/null +++ b/__tests__/components/global-error-listeners.test.tsx @@ -0,0 +1,112 @@ +/** + * End-to-end wiring for the dashboard's global error listeners: real window + * events in, capture decisions out. + * + * The bug this guards: these listeners are page-global, so browser extensions + * sharing the page had their failures reported as failproofai's. A live + * MetaMask rejection reached PostHog as an unhandled_rejection. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render } from "@testing-library/react"; +import { GlobalErrorListeners } from "@/app/components/global-error-listeners"; + +const h = vi.hoisted(() => ({ captureClientEvent: vi.fn() })); +vi.mock("@/lib/client-telemetry", () => ({ captureClientEvent: h.captureClientEvent })); + +const APP = window.location.origin; + +/** Dispatch a real unhandledrejection event with the given reason. */ +function rejectWith(reason: unknown) { + const event = new Event("unhandledrejection") as Event & { reason: unknown }; + event.reason = reason; + window.dispatchEvent(event); +} + +/** Dispatch a real error event carrying `error` and `filename`. */ +function throwWith(error: Error | undefined, filename: string) { + const event = new Event("error") as Event & { + error?: Error; + filename: string; + message: string; + lineno: number; + colno: number; + }; + event.error = error; + event.filename = filename; + event.message = error?.message ?? "boom"; + event.lineno = 1; + event.colno = 1; + window.dispatchEvent(event); +} + +function errorFrom(origin: string, message: string): Error { + const err = new Error(message); + err.stack = `Error: ${message}\n at fn (${origin}/_next/static/chunks/main.js:1:1)`; + return err; +} + +describe("GlobalErrorListeners", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reports an unhandled rejection thrown by our own code", () => { + render(); + + rejectWith(errorFrom(APP, "state is undefined")); + + expect(h.captureClientEvent).toHaveBeenCalledWith( + "unhandled_rejection", + expect.objectContaining({ error_message: "state is undefined" }), + ); + }); + + // The exact production case that prompted this filter. + it("ignores MetaMask's rejection instead of reporting it as ours", () => { + render(); + + const metamask = new Error("Failed to connect to MetaMask"); + metamask.name = "i"; + metamask.stack = + "i: Failed to connect to MetaMask\n at chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/inpage.js:1:1"; + rejectWith(metamask); + + expect(h.captureClientEvent).not.toHaveBeenCalled(); + }); + + it("ignores a rejection with no stack to attribute it by", () => { + render(); + + rejectWith("just a string"); + + expect(h.captureClientEvent).not.toHaveBeenCalled(); + }); + + it("reports an uncaught exception from our own script", () => { + render(); + + throwWith(errorFrom(APP, "render failed"), `${APP}/_next/static/chunks/main.js`); + + expect(h.captureClientEvent).toHaveBeenCalledWith( + "unhandled_exception", + expect.objectContaining({ error_message: "render failed" }), + ); + }); + + it("ignores an uncaught exception from an extension's script", () => { + render(); + + throwWith(undefined, "chrome-extension://abc/inpage.js"); + + expect(h.captureClientEvent).not.toHaveBeenCalled(); + }); + + it("stops listening once unmounted", () => { + const { unmount } = render(); + unmount(); + + rejectWith(errorFrom(APP, "after unmount")); + + expect(h.captureClientEvent).not.toHaveBeenCalled(); + }); +}); diff --git a/__tests__/lib/error-origin.test.ts b/__tests__/lib/error-origin.test.ts new file mode 100644 index 00000000..450b9ae1 --- /dev/null +++ b/__tests__/lib/error-origin.test.ts @@ -0,0 +1,111 @@ +// @vitest-environment node +/** + * Attribution for browser errors. + * + * The bug this guards: window's error/unhandledrejection listeners are + * page-global, so browser extensions injected into the dashboard reported their + * failures as ours. A live MetaMask rejection ("Failed to connect to MetaMask") + * arrived in PostHog as a failproofai unhandled_rejection. + */ +import { describe, it, expect } from "vitest"; +import { classifyErrorOrigin, isAppError } from "../../lib/error-origin"; + +const APP = "http://localhost:8020"; + +describe("classifyErrorOrigin", () => { + describe("browser extensions", () => { + // The exact shape observed in production. + it("classifies the real MetaMask rejection as an extension error", () => { + const stack = + "i: Failed to connect to MetaMask\n" + + " at chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/inpage.js:1:12345"; + + expect(classifyErrorOrigin({ stack, appOrigin: APP })).toBe("extension"); + }); + + it.each([ + ["chrome", "chrome-extension://abc/inpage.js:1:1"], + ["firefox", "moz-extension://abc/content.js:1:1"], + ["safari", "safari-web-extension://abc/injected.js:1:1"], + ["edge", "ms-browser-extension://abc/script.js:1:1"], + ])("recognises %s extension frames", (_browser, frame) => { + expect(classifyErrorOrigin({ stack: `Error: x\n at ${frame}`, appOrigin: APP })).toBe( + "extension", + ); + }); + + // Matching the shared `-extension://` suffix, not a vendor list, so a new + // browser's scheme is filtered without a code change. + it("recognises an unknown vendor's extension scheme", () => { + expect( + classifyErrorOrigin({ stack: "at future-extension://abc/x.js:1:1", appOrigin: APP }), + ).toBe("extension"); + }); + + it("uses the filename when there is no stack", () => { + expect( + classifyErrorOrigin({ filename: "chrome-extension://abc/inpage.js", appOrigin: APP }), + ).toBe("extension"); + }); + + // An error routed through an extension is the extension's problem, even + // though one of our frames appears further down the stack. + it("treats a mixed stack as an extension error", () => { + const stack = + "Error: boom\n" + + " at chrome-extension://abc/inpage.js:1:1\n" + + ` at fn (${APP}/_next/static/chunks/main.js:2:2)`; + + expect(classifyErrorOrigin({ stack, appOrigin: APP })).toBe("extension"); + }); + }); + + describe("our own dashboard", () => { + it("classifies a stack from our origin as an app error", () => { + const stack = `TypeError: x is not a function\n at Page (${APP}/_next/static/chunks/page.js:1:1)`; + + expect(classifyErrorOrigin({ stack, appOrigin: APP })).toBe("app"); + }); + + it("classifies a filename from our origin as an app error", () => { + expect( + classifyErrorOrigin({ filename: `${APP}/_next/static/chunks/main.js`, appOrigin: APP }), + ).toBe("app"); + }); + + it("attributes correctly on a non-default origin", () => { + const origin = "http://127.0.0.1:3999"; + + expect(classifyErrorOrigin({ stack: `at fn (${origin}/x.js:1:1)`, appOrigin: origin })).toBe( + "app", + ); + }); + }); + + describe("unattributable", () => { + it("returns unknown when there is no location information at all", () => { + expect(classifyErrorOrigin({ appOrigin: APP })).toBe("unknown"); + expect(classifyErrorOrigin({ stack: "", filename: "", appOrigin: APP })).toBe("unknown"); + expect(classifyErrorOrigin({ stack: " ", appOrigin: APP })).toBe("unknown"); + }); + + // The opaque cross-origin error — nothing in it to debug. + it("returns unknown for a bare cross-origin 'Script error.'", () => { + expect(classifyErrorOrigin({ stack: "Script error.", appOrigin: APP })).toBe("unknown"); + }); + + it("returns unknown for a third-party host that is neither ours nor an extension", () => { + expect( + classifyErrorOrigin({ stack: "at https://cdn.example.com/x.js:1:1", appOrigin: APP }), + ).toBe("unknown"); + }); + }); +}); + +describe("isAppError", () => { + it("reports only positively-attributed app errors", () => { + expect(isAppError({ stack: `at fn (${APP}/x.js:1:1)`, appOrigin: APP })).toBe(true); + expect(isAppError({ stack: "at chrome-extension://abc/x.js:1:1", appOrigin: APP })).toBe(false); + expect(isAppError({ appOrigin: APP })).toBe(false); + }); +}); diff --git a/app/components/global-error-listeners.tsx b/app/components/global-error-listeners.tsx index e50fe076..ecfa35f1 100644 --- a/app/components/global-error-listeners.tsx +++ b/app/components/global-error-listeners.tsx @@ -2,10 +2,27 @@ import { useEffect } from "react"; import { captureClientEvent } from "@/lib/client-telemetry"; +import { isAppError } from "@/lib/error-origin"; +/** + * Reports uncaught dashboard errors. + * + * Both listeners are page-global, so they also see failures from browser + * extensions injected into the page (MetaMask and friends). Those are filtered + * out — see lib/error-origin.ts — so this reports only our own failures. + */ export function GlobalErrorListeners() { useEffect(() => { const handleError = (event: ErrorEvent) => { + if ( + !isAppError({ + stack: event.error?.stack, + filename: event.filename, + appOrigin: window.location.origin, + }) + ) { + return; + } captureClientEvent("unhandled_exception", { error_message: event.message, error_name: event.error?.name, @@ -17,6 +34,16 @@ export function GlobalErrorListeners() { const handleUnhandledRejection = (event: PromiseRejectionEvent) => { const reason = event.reason; + // A rejection carries no filename, so the stack is the only evidence of + // where it came from. A non-Error reason has none and stays unattributed. + if ( + !isAppError({ + stack: reason instanceof Error ? reason.stack : undefined, + appOrigin: window.location.origin, + }) + ) { + return; + } captureClientEvent("unhandled_rejection", { error_message: reason instanceof Error ? reason.message : String(reason), error_name: reason instanceof Error ? reason.name : undefined, diff --git a/lib/error-origin.ts b/lib/error-origin.ts new file mode 100644 index 00000000..4025fa96 --- /dev/null +++ b/lib/error-origin.ts @@ -0,0 +1,67 @@ +/** + * Works out whether a browser error actually came from the dashboard. + * + * `window`'s "error" and "unhandledrejection" events are page-global: browser + * extensions inject content scripts into the same page and share the same + * window, so their failures reach our listeners and get reported as ours. The + * observed case was MetaMask's "Failed to connect to MetaMask" arriving as a + * failproofai `unhandled_rejection`. Extensions are user-installed and + * open-ended, so the noise is unbounded and unrelated to our code. + * + * We therefore attribute positively — report an error only when it can be + * traced to our own origin — rather than maintaining a denylist of extensions + * we happen to know about. React render errors don't depend on this: the error + * boundaries (`app/global-error.tsx`, `app/components/error-fallback.tsx`) + * report `client_error` directly, and React only invokes those for errors thrown + * inside our own tree. + */ + +/** + * Any `-extension://` URL — `chrome-extension://`, `moz-extension://`, + * `safari-web-extension://`, `ms-browser-extension://`. Matching the shared + * suffix rather than an explicit vendor list means a new browser's scheme is + * filtered the day it ships. + */ +const EXTENSION_URL = /\b[\w-]+-extension:\/\//i; + +export type ErrorOrigin = + /** Traceable to our own origin — a real dashboard failure. */ + | "app" + /** Came from a browser extension sharing the page. */ + | "extension" + /** Not attributable: no stack, or a cross-origin "Script error.". */ + | "unknown"; + +export interface ErrorOriginInput { + /** `error.stack` / `reason.stack`, when the thrown value was an Error. */ + stack?: string | null; + /** `ErrorEvent.filename` — the script URL. Absent on rejections. */ + filename?: string | null; + /** Typically `window.location.origin`. */ + appOrigin: string; +} + +/** + * Classify where an error came from, using whatever location information the + * browser gave us. + * + * Extension frames are checked first: an error whose stack passes through an + * extension is that extension's problem even if one of our frames appears + * further down. + */ +export function classifyErrorOrigin({ stack, filename, appOrigin }: ErrorOriginInput): ErrorOrigin { + const text = [filename, stack].filter(Boolean).join("\n"); + if (!text.trim()) return "unknown"; + if (EXTENSION_URL.test(text)) return "extension"; + if (appOrigin && text.includes(appOrigin)) return "app"; + return "unknown"; +} + +/** + * Whether an error is ours to report. Only positively-attributed errors qualify: + * `unknown` covers cross-origin "Script error." and stack-less rejections, which + * are unactionable anyway — there's nothing in them to debug. + */ +export function isAppError(input: ErrorOriginInput): boolean { + return classifyErrorOrigin(input) === "app"; +}