Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/effect-sdk-browser-error-noise.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions apps/web/src/page/kit/time-range-picker.scene.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
10 changes: 7 additions & 3 deletions apps/web/src/page/kit/time-range-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/shell/subscription.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -47,7 +47,7 @@ export const globalShortcuts: Stream.Stream<Message> = Dom.streamFromEventFilter
target: () => document,
type: "keydown",
filterMapEvent: (event): Option.Option<Message> => {
const key = event.key.toLowerCase()
const key = pressedKey(event).toLowerCase()
if ((event.metaKey || event.ctrlKey) && key === "k") {
event.preventDefault()
return Option.some(Message.PressedPaletteShortcut())
Expand Down
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/effect-sdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
60 changes: 60 additions & 0 deletions packages/effect-sdk/src/client/error-noise.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
10 changes: 10 additions & 0 deletions packages/effect-sdk/src/client/error-noise.ts
Original file line number Diff line number Diff line change
@@ -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)
33 changes: 33 additions & 0 deletions packages/effect-sdk/src/client/flushable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ const spanNames = (calls: ReadonlyArray<FetchCall>): Array<string> =>
).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 = () => {
Expand All @@ -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] ?? []) {
Expand Down Expand Up @@ -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()
Expand Down
4 changes: 3 additions & 1 deletion packages/effect-sdk/src/client/flushable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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: {
Expand All @@ -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" },
Expand Down
4 changes: 2 additions & 2 deletions packages/tsconfig.effect-sdk.dts.json
Original file line number Diff line number Diff line change
@@ -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"]
}
3 changes: 3 additions & 0 deletions packages/ui/src/lib/keyboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading