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
83 changes: 56 additions & 27 deletions docs/browser-sdk.md

Large diffs are not rendered by default.

14 changes: 8 additions & 6 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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. */
Expand Down
5 changes: 5 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -138,6 +141,7 @@ export interface ResolvedConfig {
readonly tracingCaptureErrors: boolean
readonly propagateTraceHeaderCorsUrls: ReadonlyArray<string | RegExp>
readonly tracingSampleRate: number
readonly errorFilters: ErrorFilterOptions
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly maskAllInputs: boolean
Expand Down Expand Up @@ -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,
Expand Down
56 changes: 56 additions & 0 deletions packages/browser/src/error-causes.test.ts
Original file line number Diff line number Diff line change
@@ -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" })
})
})
60 changes: 60 additions & 0 deletions packages/browser/src/error-causes.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>([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
}
116 changes: 116 additions & 0 deletions packages/browser/src/error-filters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
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
}
/** 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))

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(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(check(errorWith("ResizeObserver loop limit exceeded"))).toBe(true)
})

it("drops errors whose Name: message matches ignore", () => {
configureErrorFilters({ ignore: ["ChunkLoadError", /^AbortError: /] })
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(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("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)
configureErrorFilters({ allowUrls: [/^https:\/\/app\.test\//] })
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", () => {
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(check(errorWith("keep me"))).toBe(true)
})
})
82 changes: 82 additions & 0 deletions packages/browser/src/error-filters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
// 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<string | RegExp>
/** Report only errors whose top frame's script URL matches one of these. Errors with no frames are kept. */
readonly allowUrls?: ReadonlyArray<string | RegExp>
/** Drop errors whose top frame's script URL matches one of these. */
readonly denyUrls?: ReadonlyArray<string | RegExp>
/** 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<string | RegExp>): boolean =>
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 = {}

export function configureErrorFilters(next: ErrorFilterOptions | undefined): void {
options = next ?? {}
}

/**
* 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 = 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
}
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
}
}
Loading
Loading