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
31 changes: 31 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ Every field accepted by `MapleBrowser.init`:
| `webVitals` | `boolean` | `true` | Report Core Web Vitals as `browser.web_vital` log events. See [Web Vitals](#web-vitals). |
| `breadcrumbs` | `boolean` | `true` | Keep the last clicks, inputs, navigations and console lines, and export them with the next error. See [Breadcrumbs](#breadcrumbs). |
| `logs.captureConsole` | `ConsoleLevel[]` | `[]` | Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. |
| `reporting.csp` | `boolean` | `true` | Content Security Policy violations as `maple.browser.csp_violation` WARN logs. See [Browser reports](#browser-reports). |
| `reporting.browserReports` | `boolean` | `false` | Browser deprecation and intervention reports as `maple.browser.report` WARN logs. |
| `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). |
Expand Down Expand Up @@ -283,6 +285,24 @@ 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.

### Failed HTTP requests

A `fetch` or `XMLHttpRequest` response status is not an error on its own: a 404 from a search box
is usually expected. List the statuses that should become errors (and so issues) in
`errors.captureHttpStatus`:

```ts
MapleBrowser.init({
// ...
errors: { captureHttpStatus: [[500, 599], 429] },
})
```

A matching span gets status `Error`, `error.type` set to the status code (per the HTTP semantic
conventions) and `error.message` like `POST https://api.example.com/users/42 -> 503`, without the
query string. Issues group by status and request, with ids in the path redacted. Network failures
(no response at all) are always errors.

### Breadcrumbs

The SDK keeps the last 50 clicks, inputs, navigations and console lines in memory. Nothing is sent
Expand Down Expand Up @@ -339,6 +359,17 @@ Each event carries `session.id` and is linked to the page's `pageload` span when
`startNavigation`. CLS, INP and LCP settle when the page is hidden, so they arrive then. Turn them off
with `webVitals: false`.

## Browser reports

Content Security Policy violations are reported as `maple.browser.csp_violation` WARN log events with
`maple.csp.effective_directive`, `maple.csp.blocked_uri`, `maple.csp.disposition`, `url.full` and,
when the browser knows it, `code.file.path` / `code.line.number`. Set `reporting.browserReports: true`
to also get deprecation and intervention reports as `maple.browser.report` events. Both are logs,
not errors, so they never open an issue.

Each kind of report is sent once per page (a blocked image in a loop is one report), up to 50 kinds.
Where the browser supports `ReportingObserver`, reports from before the SDK loaded are included.

## Tracing across origins

`fetch` and `XMLHttpRequest` spans send the W3C `traceparent` header to same-origin requests only. When your API lives
Expand Down
5 changes: 5 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ stack and no filename. Those are dropped rather than recorded: they all
fingerprint to one contentless issue that buries the real ones. Add
`crossorigin` to the script tag to get the real error instead.

### Failed HTTP requests

`errors: { captureHttpStatus: [[500, 599]] }` makes those `fetch`/XHR responses errors, typed by
status. By default a response status alone is not an error; a network failure always is.

### Breadcrumbs

The last 50 clicks, inputs, navigations and console lines are kept in memory and exported, as OTel
Expand Down
5 changes: 3 additions & 2 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ const BUDGET = {
*
* 16 since 2026-09: the session sampler (~0.7 kB) and the `logger` queue
* (~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.
* filters and cause chains (~0.8 kB), which run on the capture path. 17.5
* for `errors.captureHttpStatus`, applied by the span exporter.
*/
firstParty: 17,
firstParty: 17.5,
}

/** How close to a ceiling counts as worth warning about. */
Expand Down
10 changes: 10 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,12 @@ export interface MapleBrowserConfig {
/** Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. Default none. */
readonly captureConsole?: ReadonlyArray<ConsoleLevel>
}
readonly reporting?: {
/** Content Security Policy violations as `maple.browser.csp_violation` WARN logs. Default true. */
readonly csp?: boolean
/** Browser deprecation and intervention reports as `maple.browser.report` WARN logs. Default false. */
readonly browserReports?: boolean
}
/** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */
readonly errors?: ErrorFilterOptions
readonly replay?: {
Expand Down Expand Up @@ -167,6 +173,8 @@ export interface ResolvedConfig {
readonly webVitals: boolean
readonly breadcrumbs: boolean
readonly captureConsole: ReadonlyArray<ConsoleLevel>
readonly reportCsp: boolean
readonly reportBrowser: boolean
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly maskAllInputs: boolean
Expand Down Expand Up @@ -235,6 +243,8 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
webVitals: config.webVitals ?? true,
breadcrumbs: config.breadcrumbs ?? true,
captureConsole: config.logs?.captureConsole ?? [],
reportCsp: config.reporting?.csp ?? true,
reportBrowser: config.reporting?.browserReports ?? false,
replayEnabled: config.replay?.enabled ?? true,
replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate),
maskAllInputs: config.privacy?.maskAllInputs ?? true,
Expand Down
3 changes: 3 additions & 0 deletions packages/browser/src/deferred/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { onDocumentPageload } from "../navigation"
import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs"
import { recordDocumentTiming } from "./document-timing"
import { startLogs } from "./logs"
import { startReports } from "./reports"
import { startWebVitals } from "./web-vitals"

export function startDeferred(config: ResolvedConfig): () => Promise<void> {
Expand All @@ -22,13 +23,15 @@ export function startDeferred(config: ResolvedConfig): () => Promise<void> {
captureConsole: config.captureConsole,
})
setErrorRecordedHook(flushBreadcrumbs)
const stopReports = startReports({ csp: config.reportCsp, browserReports: config.reportBrowser })
const stops = [
startLogs(config),
async () => {
stopVitals()
onDocumentPageload(undefined)
setErrorRecordedHook(undefined)
stopBreadcrumbs()
stopReports()
},
]
return async () => {
Expand Down
69 changes: 69 additions & 0 deletions packages/browser/src/deferred/reports.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { afterEach, describe, expect, it, vi } from "vitest"
import { attachLogSink, resetLogsForTests } from "../logs"
import { startReports } from "./reports"

// The module under test emits through the eager queue; record what reaches it.
const emitted: Array<{ eventName?: string; body?: string; attributes: Record<string, unknown> }> = []

afterEach(() => {
emitted.length = 0
resetLogsForTests()
vi.unstubAllGlobals()
})

const violation = (blockedURI: string) =>
new SecurityPolicyViolationEvent("securitypolicyviolation", {
blockedURI,
effectiveDirective: "img-src",
violatedDirective: "img-src",
originalPolicy: "img-src 'self'",
disposition: "enforce",
documentURI: location.href,
statusCode: 200,
sourceFile: `${location.origin}/app.js`,
lineNumber: 12,
columnNumber: 4,
})

describe("startReports", () => {
it("reports a CSP violation once per kind as a WARN log event", () => {
attachLogSink((record) => emitted.push(record))
vi.stubGlobal("ReportingObserver", undefined)
const stop = startReports({ csp: true, browserReports: false })
document.dispatchEvent(violation("https://tracker.test/pixel.gif"))
document.dispatchEvent(violation("https://tracker.test/pixel.gif"))
stop()

expect(emitted).toHaveLength(1)
expect(emitted[0]?.eventName).toBe("maple.browser.csp_violation")
expect(emitted[0]?.body).toBe("img-src blocked https://tracker.test/pixel.gif")
expect(emitted[0]?.attributes["maple.csp.disposition"]).toBe("enforce")
expect(emitted[0]?.attributes["code.line.number"]).toBe(12)
})

it("reports nothing when turned off", () => {
attachLogSink((record) => emitted.push(record))
vi.stubGlobal("ReportingObserver", undefined)
const stop = startReports({ csp: false, browserReports: false })
document.dispatchEvent(violation("https://tracker.test/pixel.gif"))
stop()
expect(emitted).toEqual([])
})

it("reads CSP reports through ReportingObserver where it exists", async () => {
attachLogSink((record) => emitted.push(record))
const stop = startReports({ csp: true, browserReports: false })
const meta = document.createElement("meta")
meta.httpEquiv = "Content-Security-Policy"
meta.content = "img-src 'none'"
document.head.append(meta)
const img = document.createElement("img")
img.src = "https://blocked.test/a.png"
document.body.append(img)
await vi.waitFor(() =>
expect(emitted.some((record) => record.eventName === "maple.browser.csp_violation")).toBe(true),
)
stop()
img.remove()
})
})
133 changes: 133 additions & 0 deletions packages/browser/src/deferred/reports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// Content Security Policy violations and browser deprecation/intervention
// reports, as WARN log events. They are worth seeing but are not errors in the
// app's code, so they never become issues.
import { scrubUrl } from "@maple/browser-session"
import { emitLog, type LogAttributeValue, Severity } from "../logs"

export interface ReportOptions {
/** Content Security Policy violations. */
readonly csp: boolean
/** Deprecation and intervention reports from the browser. */
readonly browserReports: boolean
}

/** Repeats of one report (a blocked image in a loop) are sent once per page, up to this many kinds. */
const MAX_REPORTS = 50

/** The report fields this module reads, parsed out of a report body or a DOM event. */
interface ReportFields {
readonly effectiveDirective?: string | undefined
readonly blockedURL?: string | undefined
readonly disposition?: string | undefined
readonly sourceFile?: string | undefined
readonly lineNumber?: number | undefined
readonly columnNumber?: number | undefined
readonly id?: string | undefined
readonly message?: string | undefined
}

/** Report bodies expose their fields as getters that only `toJSON` serializes. */
function parseReportBody(body: unknown): ReportFields {
const raw: unknown = body === null || body === undefined ? undefined : JSON.parse(JSON.stringify(body))
const fields = new Map(typeof raw === "object" && raw !== null ? Object.entries(raw) : [])
const str = (key: string): string | undefined => {
const value = fields.get(key)
return typeof value === "string" && value !== "" ? value : undefined
}
const int = (key: string): number | undefined => {
const value = fields.get(key)
return typeof value === "number" && value > 0 ? value : undefined
}
return {
effectiveDirective: str("effectiveDirective") ?? str("violatedDirective"),
blockedURL: str("blockedURL") ?? str("blockedURI"),
disposition: str("disposition"),
sourceFile: str("sourceFile"),
lineNumber: int("lineNumber"),
columnNumber: int("columnNumber"),
id: str("id"),
message: str("message"),
}
}

function sourceAttributes(fields: ReportFields): Record<string, LogAttributeValue> {
return {
...(fields.sourceFile ? { "code.file.path": scrubUrl(fields.sourceFile) } : undefined),
...(fields.lineNumber ? { "code.line.number": fields.lineNumber } : undefined),
...(fields.columnNumber ? { "code.column.number": fields.columnNumber } : undefined),
}
}

export function startReports(options: ReportOptions): () => void {
if (!options.csp && !options.browserReports) return () => {}
const seen = new Set<string>()
const once = (key: string): boolean => {
if (seen.has(key) || seen.size >= MAX_REPORTS) return false
seen.add(key)
return true
}

const csp = (fields: ReportFields): void => {
const directive = fields.effectiveDirective ?? "unknown"
const blocked = scrubUrl(fields.blockedURL ?? "inline")
if (!once(`csp ${directive} ${blocked}`)) return
emitLog({
eventName: "maple.browser.csp_violation",
severityNumber: Severity.WARN,
severityText: "WARN",
body: `${directive} blocked ${blocked}`,
attributes: {
"maple.csp.effective_directive": directive,
"maple.csp.blocked_uri": blocked,
...(fields.disposition ? { "maple.csp.disposition": fields.disposition } : undefined),
"url.full": scrubUrl(location.href),
...sourceAttributes(fields),
},
})
}

const browserReport = (type: string, fields: ReportFields): void => {
const id = fields.id ?? "unknown"
if (!once(`${type} ${id}`)) return
emitLog({
eventName: "maple.browser.report",
severityNumber: Severity.WARN,
severityText: "WARN",
body: fields.message ?? `${type}: ${id}`,
attributes: { "maple.report.type": type, "maple.report.id": id, ...sourceAttributes(fields) },
})
}

if (typeof ReportingObserver === "function") {
const types = [
...(options.csp ? ["csp-violation"] : []),
...(options.browserReports ? ["deprecation", "intervention"] : []),
]
// Buffered: reports from before this chunk loaded are delivered too.
const observer = new ReportingObserver(
(reports) => {
for (const report of reports) {
const fields = parseReportBody(report.body)
if (report.type === "csp-violation") csp(fields)
else browserReport(report.type ?? "report", fields)
}
},
{ types, buffered: true },
)
observer.observe()
return () => observer.disconnect()
}
if (!options.csp) return () => {}
// No ReportingObserver: CSP violations still fire as a DOM event.
const onViolation = (event: SecurityPolicyViolationEvent): void =>
csp({
effectiveDirective: event.effectiveDirective,
blockedURL: event.blockedURI || undefined,
disposition: event.disposition,
sourceFile: event.sourceFile,
lineNumber: event.lineNumber,
columnNumber: event.columnNumber,
})
document.addEventListener("securitypolicyviolation", onViolation)
return () => document.removeEventListener("securitypolicyviolation", onViolation)
}
8 changes: 8 additions & 0 deletions packages/browser/src/error-filters.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Client-side error filtering: runs before an error span exists, so a dropped
// error costs nothing and never reaches an issue.

import type { HttpStatusRange } from "./http-status"

export type ErrorSource = "captureException" | "window.onerror" | "unhandledrejection"

export interface ErrorFilterHint {
Expand All @@ -19,6 +21,12 @@ export interface ErrorFilterOptions {
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
/**
* HTTP response statuses that make a `fetch`/XHR span an error (and so an
* issue), e.g. `[[500, 599]]`. Default none: a response status alone is not
* an error, and a network failure always is.
*/
readonly captureHttpStatus?: ReadonlyArray<HttpStatusRange>
/**
* Drop errors thrown from browser extensions and the benign `ResizeObserver
* loop` notices. Default true.
Expand Down
Loading
Loading