From 1994d1d3619757f3108a9a9c52420b2ff9eae64c Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:05:37 +0200 Subject: [PATCH 1/3] feat(browser): error filters and linked error causes Errors a customer's page throws are not all theirs to fix: browser extensions inject scripts, and ResizeObserver reports benign loop notices. They had no way to drop those, and every one became an issue. - errors.ignore / denyUrls / allowUrls / beforeCapture, checked before an error span exists, on both the global handlers and captureException. URL lists match the top frame's script URL, parsed from V8 and SpiderMonkey/JavaScriptCore stacks (window.onerror's filename when there is no stack). - Extension frames and ResizeObserver loop notices are dropped by default; errors.defaultFilters: false keeps them. - error.cause chains and AggregateError members (up to five) are appended to exception.stacktrace as "Caused by:" blocks after the error's own frames. Fingerprints hash the top three frames, so only errors with fewer than three frames of their own can move to a new issue. The error object is passed through untouched when nothing is linked, and `code` is kept otherwise, so exception.type never changes. - Eager budget 41 -> 42 kB, first-party 16 -> 17 kB for the capture-path code. --- docs/browser-sdk.md | 83 +++++++++++------ packages/browser/scripts/size.ts | 14 +-- packages/browser/src/config.ts | 5 ++ packages/browser/src/error-causes.test.ts | 56 ++++++++++++ packages/browser/src/error-causes.ts | 60 +++++++++++++ packages/browser/src/error-filters.test.ts | 95 ++++++++++++++++++++ packages/browser/src/error-filters.ts | 72 +++++++++++++++ packages/browser/src/errors.browser.test.ts | 36 ++++++++ packages/browser/src/errors.ts | 58 ++++++++---- packages/browser/src/index.ts | 1 + packages/browser/src/init.ts | 3 + packages/browser/src/navigation.test.ts | 1 + packages/browser/src/tracing.browser.test.ts | 1 + 13 files changed, 433 insertions(+), 52 deletions(-) create mode 100644 packages/browser/src/error-causes.test.ts create mode 100644 packages/browser/src/error-causes.ts create mode 100644 packages/browser/src/error-filters.test.ts create mode 100644 packages/browser/src/error-filters.ts diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index a7ad6d752d..e589756ee8 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -38,33 +38,34 @@ The SDK is **best-effort**: network failures in telemetry never throw into your Every field accepted by `MapleBrowser.init`: -| Option | Type | Default | Description | -| -------------------------------------- | ------------------------- | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ingestKey` | `string` | none | Public ingest key (`maple_pk_...`), used only as the `Authorization` header. Omit it behind a proxy that adds auth; see [Auth via a proxy](#auth-via-a-proxy). | -| `serviceName` | `string` | none | **Required.** Service name reported on traces and stored on replay sessions. | -| `region` | `"us" \| "eu"` | `"us"` | Region your Maple organization lives in. Ignored when `endpoint` is set. See [Regions](#regions). | -| `endpoint` | `string` | `https://ingest.maple.dev` | Maple ingest base URL. Overrides `region`. Use it for a proxy or self-hosted ingest. | -| `serviceNamespace` | `string` | none | Logical group this service belongs to, emitted as the OTel `service.namespace` resource attribute on traces. | -| `serviceVersion` | `string` | none | Service version or commit SHA, attached to traces. | -| `environment` | `string` | none | Deployment environment, e.g. `"production"`. | -| `user` | `MapleIdentity` | none | End-user identity attached to sessions and browser spans. Same shape as the `identify()` object. See [Identifying users](#identifying-users). | -| `userId` | `string` | none | **Deprecated**, use `user`. User id attached to the replay session and future browser spans. | -| `tracing.enabled` | `boolean` | `true` | Enable OTel browser tracing. | -| `tracing.instrumentFetch` | `boolean` | `true` | Auto-instrument `fetch()` to create network spans. Set `false` when another tracer (e.g. the Effect client SDK) already instruments requests. Its spans feed the session through the published sink, and turning this off avoids duplicate network spans. | -| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled rejections as error spans. See [Errors](#errors). | -| `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Tracing across origins](#tracing-across-origins). | -| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Decided per session; error spans are always exported. See [Sampling](#sampling). | -| `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. | -| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). | -| `privacy.maskAllInputs` | `boolean` | `true` | Mask all `` values in the recording. | -| `privacy.maskAllText` | `boolean` | `false` | Mask all text in the rrweb recording and omit captured click-target text from session events. | -| `privacy.persistVisitorId` | `boolean` | `true` | Store a persistent visitor id (localStorage + cookie) so unique visitors and new-vs-returning are measurable. Turning it off also purges any id already stored. | -| `privacy.crossSubdomainCookie` | `boolean` | `true` | Scope the visitor-id cookie to the registered domain so sibling subdomains share it. See [Linking a marketing site to your app](#linking-a-marketing-site-to-your-app). | -| `privacy.cookieDomain` | `string` | probed | Explicit cookie `Domain=` (no leading dot). `""` forces a host-only cookie. | -| `privacy.requireConsent` | `boolean` | `false` | Capture nothing until `MapleBrowser.setConsent(true)`. See [Consent](#consent). | -| `privacy.captureUserEmail` | `boolean` | `true` | Send `identify()`'s email through to the warehouse. | -| `privacy.respectDoNotTrack` | `boolean` | `false` | Treat `navigator.doNotTrack` like Global Privacy Control (suppresses the persistent visitor id). | -| `privacy.sanitizeUrl` | `(url: string) => string` | none | Rewrite every URL before it leaves the page. Runs after the built-in redaction. See [Privacy & masking](#privacy--masking). | +| Option | Type | Default | Description | +| -------------------------------------- | ------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ingestKey` | `string` | none | Public ingest key (`maple_pk_...`), used only as the `Authorization` header. Omit it behind a proxy that adds auth; see [Auth via a proxy](#auth-via-a-proxy). | +| `serviceName` | `string` | none | **Required.** Service name reported on traces and stored on replay sessions. | +| `region` | `"us" \| "eu"` | `"us"` | Region your Maple organization lives in. Ignored when `endpoint` is set. See [Regions](#regions). | +| `endpoint` | `string` | `https://ingest.maple.dev` | Maple ingest base URL. Overrides `region`. Use it for a proxy or self-hosted ingest. | +| `serviceNamespace` | `string` | none | Logical group this service belongs to, emitted as the OTel `service.namespace` resource attribute on traces. | +| `serviceVersion` | `string` | none | Service version or commit SHA, attached to traces. | +| `environment` | `string` | none | Deployment environment, e.g. `"production"`. | +| `user` | `MapleIdentity` | none | End-user identity attached to sessions and browser spans. Same shape as the `identify()` object. See [Identifying users](#identifying-users). | +| `userId` | `string` | none | **Deprecated**, use `user`. User id attached to the replay session and future browser spans. | +| `tracing.enabled` | `boolean` | `true` | Enable OTel browser tracing. | +| `tracing.instrumentFetch` | `boolean` | `true` | Auto-instrument `fetch()` to create network spans. Set `false` when another tracer (e.g. the Effect client SDK) already instruments requests. Its spans feed the session through the published sink, and turning this off avoids duplicate network spans. | +| `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled rejections as error spans. See [Errors](#errors). | +| `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Tracing across origins](#tracing-across-origins). | +| `tracing.sampleRate` | `number` | `1` | Fraction of sessions whose traces are exported, `0` to `1`. Decided per session; error spans are always exported. See [Sampling](#sampling). | +| `errors` | `ErrorFilterOptions` | see [Filtering errors](#filtering-errors) | Drop captured errors by message, script URL, or a `beforeCapture` hook. | +| `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. | +| `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). | +| `privacy.maskAllInputs` | `boolean` | `true` | Mask all `` values in the recording. | +| `privacy.maskAllText` | `boolean` | `false` | Mask all text in the rrweb recording and omit captured click-target text from session events. | +| `privacy.persistVisitorId` | `boolean` | `true` | Store a persistent visitor id (localStorage + cookie) so unique visitors and new-vs-returning are measurable. Turning it off also purges any id already stored. | +| `privacy.crossSubdomainCookie` | `boolean` | `true` | Scope the visitor-id cookie to the registered domain so sibling subdomains share it. See [Linking a marketing site to your app](#linking-a-marketing-site-to-your-app). | +| `privacy.cookieDomain` | `string` | probed | Explicit cookie `Domain=` (no leading dot). `""` forces a host-only cookie. | +| `privacy.requireConsent` | `boolean` | `false` | Capture nothing until `MapleBrowser.setConsent(true)`. See [Consent](#consent). | +| `privacy.captureUserEmail` | `boolean` | `true` | Send `identify()`'s email through to the warehouse. | +| `privacy.respectDoNotTrack` | `boolean` | `false` | Treat `navigator.doNotTrack` like Global Privacy Control (suppresses the persistent visitor id). | +| `privacy.sanitizeUrl` | `(url: string) => string` | none | Rewrite every URL before it leaves the page. Runs after the built-in redaction. See [Privacy & masking](#privacy--masking). | A fully specified call: @@ -257,6 +258,34 @@ Levels are `debug`, `info`, `warn` and `error`. Attribute values are strings, nu Calls before `init()` are queued. The logs SDK loads in a separate chunk right after `init()`, so it stays out of the bundle every page load has to parse first. +## Filtering errors + +Filters run before an error span exists, so a dropped error costs nothing and never becomes an +issue. They apply to the global handlers and to `captureException`. + +```ts +MapleBrowser.init({ + // ... + errors: { + ignore: ["ChunkLoadError", /^AbortError: /], // matched against "Name: message" + denyUrls: [/widgets\.example\.net/], // matched against the top frame's script URL + allowUrls: [/^https:\/\/app\.example\.com\//], // errors with no frames are kept + beforeCapture: (error, { source, originalError }) => !error.message.includes("401"), + }, +}) +``` + +Errors thrown from browser extensions (`chrome-extension://`, `moz-extension://`, +`safari-web-extension://`) and the benign `ResizeObserver loop` notices are dropped by default. Set +`errors.defaultFilters: false` to keep them. + +### Linked errors + +`error.cause` chains and the members of an `AggregateError` (up to five linked errors) are appended +to `exception.stacktrace` as `Caused by:` blocks after the error's own frames. Issues are +fingerprinted on the top frames, so adding a cause does not split an existing issue unless the +error's own stack has fewer than three frames. + ## Tracing across origins `fetch` spans send the W3C `traceparent` header to same-origin requests only. When your API lives diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index d70905b020..42d657374b 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -24,11 +24,12 @@ import { gzipSync } from "node:zlib" /** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */ const BUDGET = { /** - * 41 since 2026-09: per-session trace sampling and the `logger` queue added - * ~2.4 kB (~1.2 kB code, the rest chunk-split overhead now that a second - * chunk shares the OTel core). Was 38 for navigation spans. + * 42 since 2026-09: error filters and cause chains (~0.8 kB). 41 before that: + * per-session trace sampling and the `logger` queue added ~2.4 kB (~1.2 kB + * code, the rest chunk-split overhead now that a second chunk shares the OTel + * core). Was 38 for navigation spans. */ - eager: 41, + eager: 42, /** Every page load, after `init()`: the OTel logs SDK and exporter. */ deferred: 8, lazy: 68, @@ -55,9 +56,10 @@ const BUDGET = { * into their own bundle, so for them this is a move rather than a cost. * * 16 since 2026-09: the session sampler (~0.7 kB) and the `logger` queue - * (~0.5 kB), both needed before the deferred chunk lands. + * (~0.5 kB), both needed before the deferred chunk lands. 17 for error + * filters and cause chains (~0.8 kB), which run on the capture path. */ - firstParty: 16, + firstParty: 17, } /** How close to a ceiling counts as worth warning about. */ diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 6b6139ae8b..70e2ed9fee 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -7,6 +7,7 @@ import { resolveIngestEndpoint, warnIfKeylessMapleIngest, } from "@maple/browser-session" +import type { ErrorFilterOptions } from "./error-filters" /** Public configuration for `MapleBrowser.init`. */ export interface MapleBrowserConfig { @@ -74,6 +75,8 @@ export interface MapleBrowserConfig { */ readonly sampleRate?: number } + /** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */ + readonly errors?: ErrorFilterOptions readonly replay?: { /** Default true. */ readonly enabled?: boolean @@ -138,6 +141,7 @@ export interface ResolvedConfig { readonly tracingCaptureErrors: boolean readonly propagateTraceHeaderCorsUrls: ReadonlyArray readonly tracingSampleRate: number + readonly errorFilters: ErrorFilterOptions readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -201,6 +205,7 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { tracingCaptureErrors: config.tracing?.captureErrors ?? true, propagateTraceHeaderCorsUrls: config.tracing?.propagateTraceHeaderCorsUrls ?? [], tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate), + errorFilters: config.errors ?? {}, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), maskAllInputs: config.privacy?.maskAllInputs ?? true, diff --git a/packages/browser/src/error-causes.test.ts b/packages/browser/src/error-causes.test.ts new file mode 100644 index 0000000000..0a8205538c --- /dev/null +++ b/packages/browser/src/error-causes.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest" +import { exceptionOf, stackWithCauses } from "./error-causes" + +const withStack = (error: Error, frames: string): Error => { + error.stack = `${error.name}: ${error.message}\n${frames}` + return error +} + +describe("stackWithCauses", () => { + it("returns the stack untouched when nothing is linked", () => { + const error = withStack(new Error("top"), " at a (https://app.test/a.js:1:1)") + expect(stackWithCauses(error)).toBe(error.stack) + expect(exceptionOf(error)).toBe(error) + }) + + it("appends the cause chain after the error's own frames", () => { + const root = withStack(new TypeError("socket closed"), " at read (https://app.test/net.js:5:1)") + const error = withStack( + new Error("load failed", { cause: root }), + " at load (https://app.test/a.js:1:1)", + ) + expect(stackWithCauses(error)).toBe( + [ + "Error: load failed", + " at load (https://app.test/a.js:1:1)", + "Caused by: TypeError: socket closed", + " at read (https://app.test/net.js:5:1)", + ].join("\n"), + ) + }) + + it("renders a non-Error cause and the members of an AggregateError", () => { + const aggregate = new AggregateError([new Error("one"), "two"], "all failed") + aggregate.stack = "AggregateError: all failed" + const stack = stackWithCauses(new Error("outer", { cause: aggregate })) ?? "" + expect(stack).toContain("Caused by: AggregateError: all failed") + expect(stack).toContain("Caused by: Error: one") + expect(stack).toContain("Caused by: two") + }) + + it("stops at a cycle and at five linked errors", () => { + const a = new Error("a") + const b = new Error("b", { cause: a }) + Object.defineProperty(a, "cause", { value: b }) + expect(stackWithCauses(a)?.match(/Caused by/g)).toHaveLength(1) + + let deep = new Error("0") + for (let i = 1; i <= 10; i++) deep = new Error(String(i), { cause: deep }) + expect(stackWithCauses(deep)?.match(/Caused by/g)).toHaveLength(5) + }) + + it("keeps a DOMException-style code, which OTel uses as exception.type", () => { + const error = Object.assign(new Error("gone", { cause: new Error("why") }), { code: 20 }) + expect(exceptionOf(error)).toMatchObject({ code: 20, name: "Error", message: "gone" }) + }) +}) diff --git a/packages/browser/src/error-causes.ts b/packages/browser/src/error-causes.ts new file mode 100644 index 0000000000..fff432e35f --- /dev/null +++ b/packages/browser/src/error-causes.ts @@ -0,0 +1,60 @@ +// `error.cause` chains and `AggregateError.errors`, rendered into the stack +// trace as `Caused by:` blocks after the error's own frames, the way OTel Java +// records a Throwable. Fingerprints hash the top frames, so they stay put. +import type { Exception } from "@opentelemetry/api" + +/** Deep enough for real wrapping chains, bounded against a cause cycle or a huge aggregate. */ +const MAX_LINKED = 5 + +function linkedErrors(error: Error): unknown[] { + const linked: unknown[] = [] + const seen = new Set([error]) + const queue: unknown[] = [error] + while (queue.length > 0 && linked.length < MAX_LINKED) { + const current = queue.shift() + const next: unknown[] = [] + if (current instanceof AggregateError) next.push(...current.errors) + if (current instanceof Error && current.cause !== undefined) next.push(current.cause) + for (const candidate of next) { + if (seen.has(candidate) || linked.length >= MAX_LINKED) continue + seen.add(candidate) + linked.push(candidate) + queue.push(candidate) + } + } + return linked +} + +/** V8 starts a stack with a `Name: message` header; the other engines start at the first frame. */ +function framesOf(error: Error): string { + const stack = error.stack ?? "" + const header = error.message ? `${error.name}: ${error.message}` : error.name + return stack.startsWith(header) ? stack.slice(header.length).replace(/^\n/, "") : stack +} + +function describe(value: unknown): string { + if (!(value instanceof Error)) return `Caused by: ${String(value)}` + const frames = framesOf(value) + const header = `Caused by: ${value.name}: ${value.message}` + return frames ? `${header}\n${frames}` : header +} + +/** The stack trace to record for `error`, with its linked errors appended. */ +export function stackWithCauses(error: Error): string | undefined { + const linked = linkedErrors(error) + if (linked.length === 0) return error.stack + return [error.stack ?? `${error.name}: ${error.message}`, ...linked.map(describe)].join("\n") +} + +/** + * What to hand `span.recordException`: the error itself when nothing is linked, + * otherwise a copy carrying the longer stack. `code` is kept because OTel + * prefers it over `name` for `exception.type`. + */ +export function exceptionOf(error: Error): Exception { + const stack = stackWithCauses(error) + if (stack === error.stack) return error + const code = "code" in error ? error.code : undefined + const base = { name: error.name, message: error.message, stack } + return typeof code === "string" || typeof code === "number" ? { ...base, code } : base +} diff --git a/packages/browser/src/error-filters.test.ts b/packages/browser/src/error-filters.test.ts new file mode 100644 index 0000000000..7669fb09a2 --- /dev/null +++ b/packages/browser/src/error-filters.test.ts @@ -0,0 +1,95 @@ +import { afterEach, describe, expect, it } from "vitest" +import { configureErrorFilters, frameUrls, shouldCapture } from "./error-filters" + +const V8_STACK = `TypeError: x is undefined + at render (https://app.test/assets/index-abc.js:10:5) + at https://cdn.test/vendor.js:1:200` +const FIREFOX_STACK = `render@https://app.test/assets/index-abc.js:10:5 +@https://cdn.test/vendor.js:1:200` + +const errorWith = (message: string, stack?: string, name = "Error"): Error => { + const error = new Error(message) + error.name = name + error.stack = stack + return error +} +const hint = { source: "captureException", originalError: undefined } as const + +afterEach(() => configureErrorFilters(undefined)) + +describe("frameUrls", () => { + it("reads frame URLs from V8 and Firefox/Safari stacks, top first", () => { + expect(frameUrls(V8_STACK)).toEqual([ + "https://app.test/assets/index-abc.js", + "https://cdn.test/vendor.js", + ]) + expect(frameUrls(FIREFOX_STACK)).toEqual([ + "https://app.test/assets/index-abc.js", + "https://cdn.test/vendor.js", + ]) + }) + + it("ignores a URL in the message line", () => { + expect( + frameUrls("Error: failed to load https://api.test/x\n at f (https://app.test/a.js:1:1)"), + ).toEqual(["https://app.test/a.js"]) + }) +}) + +describe("shouldCapture", () => { + it("drops extension errors and ResizeObserver notices by default", () => { + const extension = errorWith( + "boom", + "Error: boom\n at x (chrome-extension://abcdef/content.js:1:1)", + ) + expect(shouldCapture(extension, hint)).toBe(false) + expect(shouldCapture(errorWith("boom"), hint, "moz-extension://abc/script.js")).toBe(false) + expect(shouldCapture(errorWith("ResizeObserver loop limit exceeded"), hint)).toBe(false) + expect(shouldCapture(errorWith("boom", V8_STACK), hint)).toBe(true) + }) + + it("keeps them when the default filters are turned off", () => { + configureErrorFilters({ defaultFilters: false }) + expect(shouldCapture(errorWith("ResizeObserver loop limit exceeded"), hint)).toBe(true) + }) + + it("drops errors whose Name: message matches ignore", () => { + configureErrorFilters({ ignore: ["ChunkLoadError", /^AbortError: /] }) + expect(shouldCapture(errorWith("Loading chunk 7 failed", undefined, "ChunkLoadError"), hint)).toBe( + false, + ) + expect(shouldCapture(errorWith("aborted", undefined, "AbortError"), hint)).toBe(false) + expect(shouldCapture(errorWith("aborted"), hint)).toBe(true) + }) + + it("matches allowUrls and denyUrls against the top frame only", () => { + configureErrorFilters({ denyUrls: ["cdn.test"] }) + expect(shouldCapture(errorWith("x", V8_STACK), hint)).toBe(true) + configureErrorFilters({ allowUrls: [/^https:\/\/app\.test\//] }) + expect(shouldCapture(errorWith("x", V8_STACK), hint)).toBe(true) + expect(shouldCapture(errorWith("x", "Error: x\n at f (https://widget.test/w.js:1:1)"), hint)).toBe( + false, + ) + expect(shouldCapture(errorWith("no frames"), hint)).toBe(true) + }) + + it("lets beforeCapture drop an error, and keeps it when the hook throws", () => { + const seen: unknown[] = [] + configureErrorFilters({ + beforeCapture: (error, { originalError }) => { + seen.push(originalError) + return error.message !== "drop me" + }, + }) + expect( + shouldCapture(errorWith("drop me"), { source: "unhandledrejection", originalError: "raw" }), + ).toBe(false) + expect(seen).toEqual(["raw"]) + configureErrorFilters({ + beforeCapture: () => { + throw new Error("hook bug") + }, + }) + expect(shouldCapture(errorWith("keep me"), hint)).toBe(true) + }) +}) diff --git a/packages/browser/src/error-filters.ts b/packages/browser/src/error-filters.ts new file mode 100644 index 0000000000..6397d596df --- /dev/null +++ b/packages/browser/src/error-filters.ts @@ -0,0 +1,72 @@ +// Client-side error filtering: runs before an error span exists, so a dropped +// error costs nothing and never reaches an issue. + +export type ErrorSource = "captureException" | "window.onerror" | "unhandledrejection" + +export interface ErrorFilterHint { + readonly source: ErrorSource + /** What was thrown, before it was normalized into an `Error`. */ + // BOUNDARY: a thrown value is unparsed by definition. + readonly originalError: unknown +} + +export interface ErrorFilterOptions { + /** Drop errors whose `Name: message` contains a string or matches a RegExp. */ + readonly ignore?: ReadonlyArray + /** Report only errors whose top frame's script URL matches one of these. Errors with no frames are kept. */ + readonly allowUrls?: ReadonlyArray + /** Drop errors whose top frame's script URL matches one of these. */ + readonly denyUrls?: ReadonlyArray + /** Return `false` to drop the error. Runs after the lists; if it throws, the error is kept. */ + readonly beforeCapture?: (error: Error, hint: ErrorFilterHint) => boolean + /** + * Drop errors thrown from browser extensions and the benign `ResizeObserver + * loop` notices. Default true. + */ + readonly defaultFilters?: boolean +} + +const EXTENSION_URL = /^(?:chrome|moz|safari(?:-web)?|ms-browser)-extension:\/\// +const BENIGN_MESSAGES = [/^ResizeObserver loop (?:limit exceeded|completed with undelivered notifications)/] + +// `at fn (url:1:2)`, `at url:1:2` (V8) and `fn@url:1:2` (SpiderMonkey, JavaScriptCore). +const FRAME_URL = /(?:^\s*at (?:.*?\()?|@)([a-z][\w+.-]*:\/\/[^\s()]+?)(?::\d+){1,2}\)?\s*$/i + +/** The script URL of each stack frame, top first. */ +export function frameUrls(stack: string | undefined): string[] { + if (!stack) return [] + const urls: string[] = [] + for (const line of stack.split("\n")) { + const url = FRAME_URL.exec(line)?.[1] + if (url) urls.push(url) + } + return urls +} + +const matches = (value: string, patterns: ReadonlyArray): boolean => + patterns.some((pattern) => (typeof pattern === "string" ? value.includes(pattern) : pattern.test(value))) + +let options: ErrorFilterOptions = {} + +export function configureErrorFilters(next: ErrorFilterOptions | undefined): void { + options = next ?? {} +} + +/** Whether `error` should be reported. `filename` stands in for a missing stack (`window.onerror`). */ +export function shouldCapture(error: Error, hint: ErrorFilterHint, filename?: string): boolean { + const text = `${error.name}: ${error.message}` + const topUrl = frameUrls(error.stack)[0] ?? filename + if (options.defaultFilters !== false) { + if (matches(error.message, BENIGN_MESSAGES)) return false + if (topUrl && EXTENSION_URL.test(topUrl)) return false + } + if (options.ignore && matches(text, options.ignore)) return false + if (topUrl && options.denyUrls && matches(topUrl, options.denyUrls)) return false + if (topUrl && options.allowUrls?.length && !matches(topUrl, options.allowUrls)) return false + if (!options.beforeCapture) return true + try { + return options.beforeCapture(error, hint) !== false + } catch { + return true + } +} diff --git a/packages/browser/src/errors.browser.test.ts b/packages/browser/src/errors.browser.test.ts index dd1dd1ff07..75d8113a7e 100644 --- a/packages/browser/src/errors.browser.test.ts +++ b/packages/browser/src/errors.browser.test.ts @@ -2,6 +2,7 @@ import { assert, beforeEach, describe, it } from "vitest" import { SpanStatusCode, trace } from "@opentelemetry/api" import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from "@opentelemetry/sdk-trace-base" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { configureErrorFilters } from "./error-filters" import { captureException, resetReportedErrorsForTests, setupErrorCapture } from "./errors" const exporter = new InMemorySpanExporter() @@ -15,6 +16,7 @@ const exceptionEventOf = (span: ReadableSpan) => span.events.find((event) => eve beforeEach(() => { exporter.reset() + configureErrorFilters(undefined) }) describe("captureException", () => { @@ -123,3 +125,37 @@ describe("setupErrorCapture", () => { stop() }) }) + +describe("filters and linked errors", () => { + it("drops what the app filters out before any span exists", () => { + configureErrorFilters({ ignore: ["ChunkLoadError"] }) + const chunk = new Error("Loading chunk 3 failed") + chunk.name = "ChunkLoadError" + captureException(chunk) + captureException(new Error("kept")) + + assert.deepEqual( + exporter + .getFinishedSpans() + .map((span) => exceptionEventOf(span)?.attributes?.["exception.message"]), + ["kept"], + ) + }) + + it("drops an uncaught error thrown from a browser extension", () => { + const stop = setupErrorCapture() + const error = new Error("injected") + error.stack = "Error: injected\n at run (chrome-extension://abc/content.js:1:1)" + window.dispatchEvent(new ErrorEvent("error", { error, message: error.message })) + stop() + assert.strictEqual(exporter.getFinishedSpans().length, 0) + }) + + it("records the cause chain in exception.stacktrace", () => { + captureException(new Error("save failed", { cause: new TypeError("network down") })) + const stacktrace = exceptionEventOf(exporter.getFinishedSpans()[0]!)?.attributes?.[ + "exception.stacktrace" + ] + assert.include(String(stacktrace), "Caused by: TypeError: network down") + }) +}) diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index cd35964215..7f70009cd5 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -11,6 +11,8 @@ // error tracking beside server-side errors rather than in a separate silo. import { scrubUrl } from "@maple/browser-session" import { context, type Span, SpanKind, SpanStatusCode } from "@opentelemetry/api" +import { exceptionOf } from "./error-causes" +import { type ErrorSource, shouldCapture } from "./error-filters" import { keepContext } from "./sampling" import { mapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -67,13 +69,22 @@ export function recordFailure(span: Span, error: unknown): void { const normalized = asError(error) if (!alreadyReported(error)) { if (span.isRecording() && typeof error === "object" && error !== null) reported.add(error) - span.recordException(normalized) + span.recordException(exceptionOf(normalized)) } span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) } -/** Record `error` on a one-off span, exported whatever the session's trace sampling. */ -function recordException(error: unknown, options: CaptureExceptionOptions): void { +/** + * Record `error` on a one-off span, exported whatever the session's trace + * sampling, unless the app's error filters drop it. + */ +function recordException( + error: unknown, + options: CaptureExceptionOptions, + source: ErrorSource, + filename?: string, +): void { + if (!shouldCapture(asError(error), { source, originalError: error }, filename)) return const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan( options.name ?? "exception", { @@ -96,7 +107,7 @@ function recordException(error: unknown, options: CaptureExceptionOptions): void */ export function captureException(error: unknown, options: CaptureExceptionOptions = {}): void { if (alreadyReported(error)) return - recordException(error, options) + recordException(error, options, "captureException") } /** @@ -120,26 +131,35 @@ export function setupErrorCapture(): () => void { const error: unknown = event.error ?? (event.message && event.filename ? new Error(event.message) : undefined) if (error === undefined || alreadyReported(error)) return - recordException(error, { - name: "browser.uncaught_error", - attributes: { - "maple.exception.source": "window.onerror", - // `code.file.path` / `code.line.number` since semconv v1.34.0. Nothing - // reads the names they replaced, so they are dropped rather than - // dual-emitted — carrying both would put four near-identical rows on - // every uncaught error in the attribute list. - ...(event.filename ? { "code.file.path": event.filename } : undefined), - ...(event.lineno ? { "code.line.number": event.lineno } : undefined), + recordException( + error, + { + name: "browser.uncaught_error", + attributes: { + "maple.exception.source": "window.onerror", + // `code.file.path` / `code.line.number` since semconv v1.34.0. Nothing + // reads the names they replaced, so they are dropped rather than + // dual-emitted — carrying both would put four near-identical rows on + // every uncaught error in the attribute list. + ...(event.filename ? { "code.file.path": event.filename } : undefined), + ...(event.lineno ? { "code.line.number": event.lineno } : undefined), + }, }, - }) + "window.onerror", + event.filename, + ) } const onUnhandledRejection = (event: PromiseRejectionEvent): void => { if (alreadyReported(event.reason)) return - recordException(event.reason, { - name: "browser.unhandled_rejection", - attributes: { "maple.exception.source": "unhandledrejection" }, - }) + recordException( + event.reason, + { + name: "browser.unhandled_rejection", + attributes: { "maple.exception.source": "unhandledrejection" }, + }, + "unhandledrejection", + ) } window.addEventListener("error", onError) diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 67ea08c16a..6f94eb34a6 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -13,6 +13,7 @@ export type { TraitValue, } from "@maple/browser-session" export type { MapleBrowserConfig } from "./config" +export type { ErrorFilterHint, ErrorFilterOptions, ErrorSource } from "./error-filters" export type { CaptureExceptionOptions } from "./errors" export type { MapleBrowserHandle } from "./init" export type { LogAttributeValue } from "./logs" diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 93184cf6ac..6925a33ebe 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -27,6 +27,7 @@ import { import type { ReplaySessionHandle } from "@maple/browser-session/replay" import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" +import { configureErrorFilters } from "./error-filters" import { setupErrorCapture } from "./errors" import { setLogIdentity } from "./logs" import { resetNavigation } from "./navigation" @@ -81,6 +82,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { pendingIdentity = undefined activeConfig = config configurePrivacy(config) + configureErrorFilters(config.errorFilters) if (!hasConsent()) clearPendingEvents() setActiveTraceIdProvider(() => trace.getActiveSpan()?.spanContext().traceId) @@ -236,6 +238,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { await stopRuntime(true) stopErrorCapture?.() stopErrorCapture = undefined + configureErrorFilters(undefined) // Before the provider shuts down, so an open navigation exports with it resetNavigation() await deferredPending diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index 3776d40e6a..84b597c743 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -45,6 +45,7 @@ const CONFIG = { respectDoNotTrack: false, propagateTraceHeaderCorsUrls: [], tracingSampleRate: 1, + errorFilters: {}, sanitizeUrl: undefined, } diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index 57e89278a0..27798ac9e9 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -54,6 +54,7 @@ const CONFIG = { respectDoNotTrack: false, propagateTraceHeaderCorsUrls: [], tracingSampleRate: 1, + errorFilters: {}, sanitizeUrl: undefined, } From 4849cd648d77bd20f3e503699b900fad72cc0a3a Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:12:15 +0200 Subject: [PATCH 2/3] fix(browser): judge error URL lists only on frames the page's error carries An Error wrapped around a non-Error value (a string rejection, captureException("...")) has this SDK's frames, so denyUrls/allowUrls matched Maple's bundle and window.onerror's filename was never used. Only a thrown Error's own stack is matched now; window.onerror passes its filename as the frame when no Error was thrown. --- packages/browser/src/error-filters.test.ts | 50 ++++++++++++++-------- packages/browser/src/error-filters.ts | 11 +++-- packages/browser/src/errors.ts | 3 +- 3 files changed, 43 insertions(+), 21 deletions(-) diff --git a/packages/browser/src/error-filters.test.ts b/packages/browser/src/error-filters.test.ts index 7669fb09a2..745cb034fa 100644 --- a/packages/browser/src/error-filters.test.ts +++ b/packages/browser/src/error-filters.test.ts @@ -13,7 +13,9 @@ const errorWith = (message: string, stack?: string, name = "Error"): Error => { error.stack = stack return error } -const hint = { source: "captureException", originalError: undefined } as const +/** As the SDK calls it for a thrown Error: the error is its own original. */ +const check = (error: Error, frameUrl?: string): boolean => + shouldCapture(error, { source: "captureException", originalError: error }, frameUrl) afterEach(() => configureErrorFilters(undefined)) @@ -42,35 +44,49 @@ describe("shouldCapture", () => { "boom", "Error: boom\n at x (chrome-extension://abcdef/content.js:1:1)", ) - expect(shouldCapture(extension, hint)).toBe(false) - expect(shouldCapture(errorWith("boom"), hint, "moz-extension://abc/script.js")).toBe(false) - expect(shouldCapture(errorWith("ResizeObserver loop limit exceeded"), hint)).toBe(false) - expect(shouldCapture(errorWith("boom", V8_STACK), hint)).toBe(true) + expect(check(extension)).toBe(false) + expect(check(errorWith("boom"), "moz-extension://abc/script.js")).toBe(false) + expect(check(errorWith("ResizeObserver loop limit exceeded"))).toBe(false) + expect(check(errorWith("boom", V8_STACK))).toBe(true) }) it("keeps them when the default filters are turned off", () => { configureErrorFilters({ defaultFilters: false }) - expect(shouldCapture(errorWith("ResizeObserver loop limit exceeded"), hint)).toBe(true) + expect(check(errorWith("ResizeObserver loop limit exceeded"))).toBe(true) }) it("drops errors whose Name: message matches ignore", () => { configureErrorFilters({ ignore: ["ChunkLoadError", /^AbortError: /] }) - expect(shouldCapture(errorWith("Loading chunk 7 failed", undefined, "ChunkLoadError"), hint)).toBe( - false, + expect(check(errorWith("Loading chunk 7 failed", undefined, "ChunkLoadError"))).toBe(false) + expect(check(errorWith("aborted", undefined, "AbortError"))).toBe(false) + expect(check(errorWith("aborted"))).toBe(true) + }) + + it("judges URL lists only on frames the page's own error carries", () => { + configureErrorFilters({ allowUrls: [/^https:\/\/app\.test\//] }) + // A string rejection wrapped in an Error: its stack is this SDK's, not the page's. + const wrapped = errorWith( + "rejected", + "Error: rejected\n at asError (https://cdn.test/maple.js:1:1)", ) - expect(shouldCapture(errorWith("aborted", undefined, "AbortError"), hint)).toBe(false) - expect(shouldCapture(errorWith("aborted"), hint)).toBe(true) + expect(shouldCapture(wrapped, { source: "unhandledrejection", originalError: "rejected" })).toBe(true) + // window.onerror's filename is the frame when no Error was thrown. + expect( + shouldCapture( + wrapped, + { source: "window.onerror", originalError: wrapped }, + "https://other.test/x.js", + ), + ).toBe(false) }) it("matches allowUrls and denyUrls against the top frame only", () => { configureErrorFilters({ denyUrls: ["cdn.test"] }) - expect(shouldCapture(errorWith("x", V8_STACK), hint)).toBe(true) + expect(check(errorWith("x", V8_STACK))).toBe(true) configureErrorFilters({ allowUrls: [/^https:\/\/app\.test\//] }) - expect(shouldCapture(errorWith("x", V8_STACK), hint)).toBe(true) - expect(shouldCapture(errorWith("x", "Error: x\n at f (https://widget.test/w.js:1:1)"), hint)).toBe( - false, - ) - expect(shouldCapture(errorWith("no frames"), hint)).toBe(true) + expect(check(errorWith("x", V8_STACK))).toBe(true) + expect(check(errorWith("x", "Error: x\n at f (https://widget.test/w.js:1:1)"))).toBe(false) + expect(check(errorWith("no frames"))).toBe(true) }) it("lets beforeCapture drop an error, and keeps it when the hook throws", () => { @@ -90,6 +106,6 @@ describe("shouldCapture", () => { throw new Error("hook bug") }, }) - expect(shouldCapture(errorWith("keep me"), hint)).toBe(true) + expect(check(errorWith("keep me"))).toBe(true) }) }) diff --git a/packages/browser/src/error-filters.ts b/packages/browser/src/error-filters.ts index 6397d596df..a2019854bf 100644 --- a/packages/browser/src/error-filters.ts +++ b/packages/browser/src/error-filters.ts @@ -52,10 +52,15 @@ export function configureErrorFilters(next: ErrorFilterOptions | undefined): voi options = next ?? {} } -/** Whether `error` should be reported. `filename` stands in for a missing stack (`window.onerror`). */ -export function shouldCapture(error: Error, hint: ErrorFilterHint, filename?: string): boolean { +/** + * Whether `error` should be reported. `frameUrl`, when given, is the top frame's + * script URL (`window.onerror`'s filename). Otherwise only a thrown `Error` has + * frames of its own: the stack of an `Error` wrapped around anything else points + * at this SDK, so no URL list applies to it. + */ +export function shouldCapture(error: Error, hint: ErrorFilterHint, frameUrl?: string): boolean { const text = `${error.name}: ${error.message}` - const topUrl = frameUrls(error.stack)[0] ?? filename + const topUrl = frameUrl ?? (hint.originalError instanceof Error ? frameUrls(error.stack)[0] : undefined) if (options.defaultFilters !== false) { if (matches(error.message, BENIGN_MESSAGES)) return false if (topUrl && EXTENSION_URL.test(topUrl)) return false diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index 7f70009cd5..ee6cb06004 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -146,7 +146,8 @@ export function setupErrorCapture(): () => void { }, }, "window.onerror", - event.filename, + // A thrown Error carries its own frames; anything else only has the event's filename. + event.error instanceof Error ? undefined : event.filename || undefined, ) } From c095d8812c05a5156eb39d1ea348b8f7d2cca5dd Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 23:24:33 +0200 Subject: [PATCH 3/3] fix(browser): global regexes in error filters match every error A g/y RegExp keeps lastIndex between test() calls, so ignore/denyUrls/ allowUrls with /chunk/g dropped only every other matching error. Reset lastIndex before each test. --- packages/browser/src/error-filters.test.ts | 5 +++++ packages/browser/src/error-filters.ts | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/error-filters.test.ts b/packages/browser/src/error-filters.test.ts index 745cb034fa..64180fb821 100644 --- a/packages/browser/src/error-filters.test.ts +++ b/packages/browser/src/error-filters.test.ts @@ -80,6 +80,11 @@ describe("shouldCapture", () => { ).toBe(false) }) + it("drops every matching error with a global regex, not every other one", () => { + configureErrorFilters({ ignore: [/chunk/gi] }) + expect([1, 2, 3].map(() => check(errorWith("Loading chunk failed")))).toEqual([false, false, false]) + }) + it("matches allowUrls and denyUrls against the top frame only", () => { configureErrorFilters({ denyUrls: ["cdn.test"] }) expect(check(errorWith("x", V8_STACK))).toBe(true) diff --git a/packages/browser/src/error-filters.ts b/packages/browser/src/error-filters.ts index a2019854bf..77ecd83904 100644 --- a/packages/browser/src/error-filters.ts +++ b/packages/browser/src/error-filters.ts @@ -44,7 +44,12 @@ export function frameUrls(stack: string | undefined): string[] { } const matches = (value: string, patterns: ReadonlyArray): boolean => - patterns.some((pattern) => (typeof pattern === "string" ? value.includes(pattern) : pattern.test(value))) + patterns.some((pattern) => { + if (typeof pattern === "string") return value.includes(pattern) + // A `g`/`y` regex is stateful: `test` advances `lastIndex`, so reset it first. + pattern.lastIndex = 0 + return pattern.test(value) + }) let options: ErrorFilterOptions = {}