diff --git a/.changeset/effect-sdk-browser-error-noise.md b/.changeset/effect-sdk-browser-error-noise.md new file mode 100644 index 0000000000..3f743409a3 --- /dev/null +++ b/.changeset/effect-sdk-browser-error-noise.md @@ -0,0 +1,5 @@ +--- +"@maple-dev/effect-sdk": patch +--- + +The browser client's uncaught-error capture no longer reports errors thrown by browser extensions (an extension's script, or a rejection whose top frame is one) or the benign `ResizeObserver loop` notices, as `@maple/browser` already does. diff --git a/apps/web/src/page/kit/time-range-picker.scene.test.ts b/apps/web/src/page/kit/time-range-picker.scene.test.ts index 25a2efc948..6b8c72f765 100644 --- a/apps/web/src/page/kit/time-range-picker.scene.test.ts +++ b/apps/web/src/page/kit/time-range-picker.scene.test.ts @@ -213,4 +213,8 @@ describe("time range picker hotkey", () => { expect(isOpenKey(press("D", { shiftKey: true }))).toBe(false) expect(isOpenKey(press("f"))).toBe(false) }) + + test("the key-less keydown Chrome fires on autofill opens nothing", () => { + expect(isOpenKey({ ...press("d"), key: undefined })).toBe(false) + }) }) diff --git a/apps/web/src/page/kit/time-range-picker.ts b/apps/web/src/page/kit/time-range-picker.ts index 1159695fb2..14985a9cae 100644 --- a/apps/web/src/page/kit/time-range-picker.ts +++ b/apps/web/src/page/kit/time-range-picker.ts @@ -5,7 +5,7 @@ import { parseWarehouseDateTime, relativeRangeSeconds } from "@maple/query-engin import { TONE_TEXT } from "@maple/ui/lib/tone" import { badgeVariants } from "@maple/ui/styles/ui/badge" import { kbdStyles } from "@maple/ui/styles/ui/kbd" -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" +import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Clock, Effect, Option, Schema, type Stream } from "effect" import { Command, Dom, Mount, Submodel, type Update } from "foldkit" import type { Html, HtmlBuilder } from "foldkit/html" @@ -503,7 +503,7 @@ export const SelectTimeZone = Command.define("SelectTimeZone", { const HOTKEY_ATTRIBUTE = "time-range-hotkey" type KeyPress = Readonly<{ - key: string + key?: string | undefined metaKey: boolean ctrlKey: boolean altKey: boolean @@ -512,7 +512,11 @@ type KeyPress = Readonly<{ /** The `time.open` combo: a bare "D" (either case), no modifier held. */ export const isOpenKey = (event: KeyPress): boolean => - event.key.toLowerCase() === "d" && !event.metaKey && !event.ctrlKey && !event.altKey && !event.shiftKey + pressedKey(event).toLowerCase() === "d" && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + !event.shiftKey /** * `useAppHotkey("time.open", () => setOpen(true))`: "D" outside fields and dialogs, unmodified, diff --git a/apps/web/src/shell/subscription.ts b/apps/web/src/shell/subscription.ts index 7a0ff6a59e..ebbbfcc8dd 100644 --- a/apps/web/src/shell/subscription.ts +++ b/apps/web/src/shell/subscription.ts @@ -1,4 +1,4 @@ -import { isDialogOpen, isEditableTarget } from "@maple/ui/lib/keyboard" +import { isDialogOpen, isEditableTarget, pressedKey } from "@maple/ui/lib/keyboard" import { Option, Stream } from "effect" import { Dom } from "foldkit" import { DASHBOARD_FAVORITES_EVENT, readDashboardFavorites } from "./favorites" @@ -47,7 +47,7 @@ export const globalShortcuts: Stream.Stream = Dom.streamFromEventFilter target: () => document, type: "keydown", filterMapEvent: (event): Option.Option => { - const key = event.key.toLowerCase() + const key = pressedKey(event).toLowerCase() if ((event.metaKey || event.ctrlKey) && key === "k") { event.preventDefault() return Option.some(Message.PressedPaletteShortcut()) diff --git a/bun.lock b/bun.lock index 97a4bf669f..a3d0bbc71c 100644 --- a/bun.lock +++ b/bun.lock @@ -712,6 +712,7 @@ "devDependencies": { "@effect/vitest": "catalog:effect", "@maple/browser-session": "workspace:*", + "@maple/sdk-core": "workspace:*", "@types/node": "catalog:tooling", "effect": "catalog:effect", "tsdown": "^0.23.0", diff --git a/packages/effect-sdk/package.json b/packages/effect-sdk/package.json index 793c4cb1c6..42390077b9 100644 --- a/packages/effect-sdk/package.json +++ b/packages/effect-sdk/package.json @@ -68,6 +68,7 @@ "devDependencies": { "@effect/vitest": "catalog:effect", "@maple/browser-session": "workspace:*", + "@maple/sdk-core": "workspace:*", "@types/node": "catalog:tooling", "effect": "catalog:effect", "tsdown": "^0.23.0", diff --git a/packages/effect-sdk/src/client/error-noise.test.ts b/packages/effect-sdk/src/client/error-noise.test.ts new file mode 100644 index 0000000000..7a9ea810f5 --- /dev/null +++ b/packages/effect-sdk/src/client/error-noise.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from "vitest" +import { isBrowserNoise } from "./error-noise.js" + +const withStack = (message: string, stack: string): Error => { + const error = new Error(message) + error.stack = stack + return error +} + +describe("isBrowserNoise", () => { + it("drops the ResizeObserver loop notices", () => { + expect( + isBrowserNoise( + new Error("ResizeObserver loop completed with undelivered notifications."), + "window.onerror", + "https://app.maple.dev/", + ), + ).toBe(true) + expect(isBrowserNoise(new Error("ResizeObserver loop limit exceeded"), "window.onerror")).toBe(true) + }) + + it("drops a rejection thrown from an extension's injected script", () => { + const metaMask = withStack( + "Failed to connect to MetaMask", + "i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/scripts/inpage.js:12:4822)", + ) + expect(isBrowserNoise(metaMask, "unhandledrejection")).toBe(true) + expect( + isBrowserNoise(withStack("x", "connect@moz-extension://abc/inpage.js:1:2"), "unhandledrejection"), + ).toBe(true) + }) + + it("drops an uncaught error whose script is an extension's", () => { + expect( + isBrowserNoise( + new Error("boom"), + "window.onerror", + "chrome-extension://fmkadmapgofadopljbjfkapdkoienihi/build/backendManager.js", + ), + ).toBe(true) + }) + + it("keeps the app's own errors, even when an extension frame sits further down the stack", () => { + const own = withStack( + "Cannot read properties of undefined (reading 'toLowerCase')", + "TypeError: Cannot read properties of undefined (reading 'toLowerCase')\n at isOpenKey (https://app.maple.dev/assets/time-range-picker.js:1:2)\n at chrome-extension://abc/content.js:3:4", + ) + expect(isBrowserNoise(own, "unhandledrejection")).toBe(false) + expect( + isBrowserNoise( + withStack( + "mail a@b.co failed", + "Error: mail a@b.co failed\n at send (https://app.test/a.js:1:1)", + ), + "window.onerror", + ), + ).toBe(false) + expect(isBrowserNoise("plain rejection", "unhandledrejection")).toBe(false) + }) +}) diff --git a/packages/effect-sdk/src/client/error-noise.ts b/packages/effect-sdk/src/client/error-noise.ts new file mode 100644 index 0000000000..ccff6eb6af --- /dev/null +++ b/packages/effect-sdk/src/client/error-noise.ts @@ -0,0 +1,10 @@ +// Uncaught errors that are not the app's: a browser extension's own failures and +// the benign `ResizeObserver loop` notice. The patterns are `@maple/sdk-core`'s +// default filters, the same ones `@maple/browser` applies. +import { asError, type ErrorSource, makeErrorFilter } from "@maple/sdk-core" + +const defaultFilters = makeErrorFilter() + +/** True for an error the page's own code did not throw. `filename` is `ErrorEvent.filename`. */ +export const isBrowserNoise = (error: unknown, source: ErrorSource, filename?: string): boolean => + !defaultFilters(asError(error), { source, originalError: error }, filename || undefined) diff --git a/packages/effect-sdk/src/client/flushable.test.ts b/packages/effect-sdk/src/client/flushable.test.ts index 7de5366b97..1f25164daf 100644 --- a/packages/effect-sdk/src/client/flushable.test.ts +++ b/packages/effect-sdk/src/client/flushable.test.ts @@ -79,6 +79,9 @@ const spanNames = (calls: ReadonlyArray): Array => ).resourceSpans[0].scopeSpans[0].spans.map((span) => span.name), ) +/** The `ErrorEvent` / `PromiseRejectionEvent` fields the global error handlers read. */ +type ShimEvent = Readonly<{ error?: Error | null; message?: string; filename?: string; reason?: Error }> + // Minimal DOM event shim — vitest runs in node, where globalThis isn't an // EventTarget. Lets us drive `pagehide` / `visibilitychange` without jsdom. const setupDom = () => { @@ -101,6 +104,9 @@ const setupDom = () => { fire: (type: string) => { for (const fn of listeners[type] ?? []) (fn as () => void)() }, + dispatch: (type: string, event: ShimEvent) => { + for (const fn of listeners[type] ?? []) (fn as (event: ShimEvent) => void)(event) + }, // A browser runs a microtask checkpoint after each listener; `fire` does not. fireWithCheckpoints: async (type: string) => { for (const fn of listeners[type] ?? []) { @@ -414,6 +420,33 @@ describe("MapleFlush.make (client)", () => { expect(dom.listenerCount("visibilitychange")).toBe(0) }) + it("reports the app's uncaught errors and drops extension and ResizeObserver noise", async () => { + const { calls, restore: rf } = setupFetch() + const dom = setupDom() + restore = () => { + rf() + dom.restore() + } + const telemetry = make(baseConfig) + const extensionFrame = new Error("Failed to connect to MetaMask") + extensionFrame.stack = + "i: Failed to connect to MetaMask\n at Object.connect (chrome-extension://nkbihfbeogaeaoehlefnkodbefgpgknn/scripts/inpage.js:12:4822)" + + dom.dispatch("error", { + error: null, + message: "ResizeObserver loop completed with undelivered notifications.", + filename: "https://app.test/", + }) + dom.dispatch("error", { error: new Error("boom"), filename: "chrome-extension://abc/content.js" }) + dom.dispatch("unhandledrejection", { reason: extensionFrame }) + dom.dispatch("error", { error: new TypeError("app bug"), filename: "https://app.test/assets/app.js" }) + dom.dispatch("unhandledrejection", { reason: new Error("app rejection") }) + await telemetry.flush() + + expect(spanNames(calls)).toEqual(["browser.uncaught_error", "browser.unhandled_rejection"]) + await telemetry.dispose() + }) + it("auto-flushes on the interval, and dispose() stops the timer", async () => { vi.useFakeTimers() const { calls, restore: r } = setupFetch() diff --git a/packages/effect-sdk/src/client/flushable.ts b/packages/effect-sdk/src/client/flushable.ts index a5cfbeb838..8a78df902a 100644 --- a/packages/effect-sdk/src/client/flushable.ts +++ b/packages/effect-sdk/src/client/flushable.ts @@ -32,6 +32,7 @@ import { makeMetricBuffer } from "../shared/flushable-metrics.js" import { type CaptureExceptionOptions, makeSpanBuffer, type SpanBuffer } from "../shared/flushable-tracer.js" import type { MapleSpanOptions } from "../shared/span-options.js" import { browserDocument, browserNavigator, browserResourceAttributes } from "./browser-globals.js" +import { isBrowserNoise } from "./error-noise.js" import { type ClientReplayConfig, startClientSession } from "./replay-loader.js" import { withSessionLink } from "./session-link.js" import type { PrivacyOptions } from "./track.js" @@ -359,7 +360,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => // louder error tracker. const error: unknown = event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) - if (error === undefined) return + if (error === undefined || isBrowserNoise(error, "window.onerror", event.filename)) return captureException(error, { name: "browser.uncaught_error", attributes: { @@ -375,6 +376,7 @@ export const make = (config: MapleClientFlushableConfig): FlushableTelemetry => } const onUnhandledRejection = (event: PromiseRejectionEvent): void => { + if (isBrowserNoise(event.reason, "unhandledrejection")) return captureException(event.reason, { name: "browser.unhandled_rejection", attributes: { "maple.exception.source": "unhandledrejection" }, diff --git a/packages/tsconfig.effect-sdk.dts.json b/packages/tsconfig.effect-sdk.dts.json index 442a507b2c..d1a7a1ccd2 100644 --- a/packages/tsconfig.effect-sdk.dts.json +++ b/packages/tsconfig.effect-sdk.dts.json @@ -1,7 +1,7 @@ { // Declaration emit for effect-sdk's tsdown build only. It lives here so tsgo's - // rootDir covers @maple/browser-session, whose types the SDK bundles. + // rootDir covers @maple/browser-session and @maple/sdk-core, whose types the SDK bundles. "extends": "./effect-sdk/tsconfig.json", - "include": ["./effect-sdk/src/**/*.ts", "./browser-session/src/**/*.ts"], + "include": ["./effect-sdk/src/**/*.ts", "./browser-session/src/**/*.ts", "./sdk-core/src/**/*.ts"], "exclude": ["**/*.test.ts", "**/*.bench.ts"] } diff --git a/packages/ui/src/lib/keyboard.ts b/packages/ui/src/lib/keyboard.ts index 4ded726931..4dd84f0814 100644 --- a/packages/ui/src/lib/keyboard.ts +++ b/packages/ui/src/lib/keyboard.ts @@ -3,6 +3,9 @@ const EDITABLE_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]) +/** `event.key`, or "" for the key-less `keydown` Chrome fires when it autofills a form. */ +export const pressedKey = (event: { readonly key?: string | undefined }): string => event.key ?? "" + /** True when the event target is a text field / contentEditable — skip shortcuts there. */ export function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false