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
18 changes: 18 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ Every field accepted by `MapleBrowser.init`:
| `tracing.propagateTraceHeaderCorsUrls` | `Array<string \| RegExp>` | `[]` | 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). |
| `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"]`. |
| `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 @@ -281,6 +283,22 @@ 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.

### Breadcrumbs

The SDK keeps the last 50 clicks, inputs, navigations and console lines in memory. Nothing is sent
until an error is recorded: then the trail is exported as OpenTelemetry log records linked to the
error's span, so it shows up on the error's trace. Each breadcrumb is sent once; the next error gets
the trail since the last one.

- Clicks, inputs and navigations are `maple.browser.breadcrumb` events with `maple.breadcrumb.type`,
`maple.breadcrumb.target` (a short selector, never an input value) and `url.full`.
- Console lines are ordinary log records at the console call's severity, with
`maple.breadcrumb.type: "console"`.

Collection starts with the SDK's deferred chunk, a moment after `init()`. Turn it off with
`breadcrumbs: false`. To send console output as logs whether or not an error follows, list the
levels in `logs.captureConsole`; those lines are exported right away and not kept as breadcrumbs.

### Linked errors

`error.cause` chains and the members of an `AggregateError` (up to five linked errors) are appended
Expand Down
3 changes: 2 additions & 1 deletion packages/browser-session/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
".": "./src/index.ts",
"./replay": "./src/session/replay-session.ts",
"./props": "./src/events/props.ts",
"./region": "./src/platform/region.ts"
"./region": "./src/platform/region.ts",
"./console": "./src/replay/capture/console.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
Expand Down
27 changes: 27 additions & 0 deletions packages/browser-session/src/events/events-sink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,27 @@ export interface SessionEvent {
attrs?: Record<string, string>
}

type SessionEventListener = (ev: SessionEvent) => void

/** On `globalThis` like the sink: every bundled copy of this module must see the same listeners. */
const LISTENERS_KEY = "__MAPLE_SESSION_EVENT_LISTENERS__"

function listeners(): Set<SessionEventListener> {
const global = globalThis as typeof globalThis & Record<string, Set<SessionEventListener> | undefined>
let set = global[LISTENERS_KEY]
if (!set) {
set = new Set()
global[LISTENERS_KEY] = set
}
return set
}

/** Observe every event the live sink records, e.g. for an error's breadcrumb trail. Returns an unsubscribe. */
export function onSessionEvent(listener: SessionEventListener): () => void {
listeners().add(listener)
return () => listeners().delete(listener)
}

const FLUSH_INTERVAL_MS = 5_000
const FLUSH_BYTES = 64 * 1024

Expand Down Expand Up @@ -143,6 +164,12 @@ export function startEventSink(config: IngestConfig, sessionId: string): Session
buffer.push({ ev, seq: seq++ })
bufferBytes += approximateSize(ev)
if (bufferBytes >= FLUSH_BYTES) void flush()
for (const listener of listeners()) {
// A listener must never break capture.
try {
listener(ev)
} catch {}
}
}

// Navigation is observed by the sink rather than by a capture module: page
Expand Down
1 change: 1 addition & 0 deletions packages/browser-session/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type { SessionEvent, SessionEventSink } from "./events/events-sink"
export {
clearPendingEvents,
getActiveSink,
onSessionEvent,
setActiveTraceIdProvider,
startEventSink,
} from "./events/events-sink"
Expand Down
24 changes: 24 additions & 0 deletions packages/browser-session/src/replay/capture/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,28 @@ describe("installConsoleCapture", () => {
expect(message.startsWith("[0,1,2,")).toBe(true)
expect(message.length).toBeLessThanOrEqual(2_001)
})

it("keeps a capture installed on top working when the one underneath is torn down", () => {
console.debug = () => {}
const inner: SessionEvent[] = []
const outer: SessionEvent[] = []
const stopInner = installConsoleCapture((event) => inner.push(event))
const stopOuter = installConsoleCapture((event) => outer.push(event))
stopInner()
console.debug("after inner stopped")
expect(outer.map((event) => event.message)).toEqual(["after inner stopped"])
expect(inner).toEqual([])
stopOuter()
})

it("keeps the capture underneath working when the one on top is torn down", () => {
console.debug = () => {}
const inner: SessionEvent[] = []
const stopInner = installConsoleCapture((event) => inner.push(event))
const stopOuter = installConsoleCapture(() => {})
stopOuter()
console.debug("after outer stopped")
expect(inner.map((event) => event.message)).toEqual(["after outer stopped"])
stopInner()
})
})
14 changes: 11 additions & 3 deletions packages/browser-session/src/replay/capture/console.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,29 @@ const MAX_ARRAY_ELEMENTS = 100
*/
export function installConsoleCapture(emit: Emit): () => void {
const original: Partial<Record<Level, (...args: unknown[]) => void>> = {}
const wrappers: Partial<Record<Level, (...args: unknown[]) => void>> = {}
// Two captures can stack (breadcrumbs and replay). After teardown a wrapper
// still inside someone else's chain only forwards.
let active = true

for (const level of LEVELS) {
const orig = console[level] as (...args: unknown[]) => void
original[level] = orig
console[level] = (...args: unknown[]) => {
const wrapper = (...args: unknown[]): void => {
// Capture must never break the host app's logging.
safeEmit(emit, { type: "console", level, message: formatArgs(args) })
if (active) safeEmit(emit, { type: "console", level, message: formatArgs(args) })
orig.apply(console, args)
}
wrappers[level] = wrapper
console[level] = wrapper as never
}

return () => {
active = false
for (const level of LEVELS) {
const orig = original[level]
if (orig) console[level] = orig as never
// Only undo our own wrapper: one installed on top of it stays, and keeps working.
if (orig && console[level] === wrappers[level]) console[level] = orig as never
}
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/browser/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@ 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.

### Breadcrumbs

The last 50 clicks, inputs, navigations and console lines are kept in memory and exported, as OTel
log records linked to the error's span, only when an error is recorded. `breadcrumbs: false` turns
this off; `logs: { captureConsole: ["warn", "error"] }` sends those console levels as logs right away.

## Bundle size

Bundled, minified and gzipped, as your bundler would ship it:
Expand Down
126 changes: 126 additions & 0 deletions packages/browser/src/breadcrumbs.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam.
import type { ReadableLogRecord } from "@opentelemetry/sdk-logs"
import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

const exportedSpans: ReadableSpan[] = []
const exportedLogs: ReadableLogRecord[] = []
const exporter = <T>(sink: T[]) =>
class {
export(items: T[], callback: (result: { code: number }) => void): void {
sink.push(...items)
callback({ code: 0 })
}
forceFlush(): Promise<void> {
return Promise.resolve()
}
shutdown(): Promise<void> {
return Promise.resolve()
}
}
vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ OTLPTraceExporter: exporter(exportedSpans) }))
vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ OTLPLogExporter: exporter(exportedLogs) }))

const { MapleBrowser } = await import("./index")
const { resetReportedErrorsForTests } = await import("./errors")

type InitConfig = Parameters<typeof MapleBrowser.init>[0]
const BASE: InitConfig = {
ingestKey: "k",
serviceName: "web",
endpoint: "https://ingest.test",
replay: { enabled: false },
tracing: { instrumentFetch: false, instrumentXhr: false },
webVitals: false,
}

let handle: ReturnType<typeof MapleBrowser.init> | undefined
const stop = async (): Promise<void> => {
await handle?.shutdown()
handle = undefined
}

beforeEach(() => {
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}")),
)
})

afterEach(async () => {
await stop()
exportedSpans.length = 0
exportedLogs.length = 0
resetReportedErrorsForTests()
document.body.replaceChildren()
vi.unstubAllGlobals()
})

/** Breadcrumbs start with the deferred chunk, a moment after `init()`. */
const init = async (config: InitConfig): Promise<void> => {
handle = MapleBrowser.init(config)
await import("./deferred")
await new Promise((resolve) => setTimeout(resolve, 0))
}

const clickButton = (id: string): void => {
const button = document.createElement("button")
button.id = id
button.textContent = "Save"
document.body.append(button)
button.click()
}

describe("breadcrumbs", () => {
it("exports the trail before an error as logs linked to the error span", async () => {
await init(BASE)
clickButton("save")
console.info("saving draft")
MapleBrowser.captureException(new Error("save failed"))
await stop()

const error = exportedSpans.find((span) => span.name === "exception")
const click = exportedLogs.find((log) => log.attributes["maple.breadcrumb.type"] === "click")
const line = exportedLogs.find((log) => log.body === "saving draft")
expect(click?.eventName).toBe("maple.browser.breadcrumb")
expect(click?.attributes["maple.breadcrumb.target"]).toBe("button#save")
expect(click?.spanContext?.spanId).toBe(error?.spanContext().spanId)
expect(line?.severityText).toBe("INFO")
expect(line?.attributes["maple.breadcrumb.type"]).toBe("console")
expect(line?.spanContext?.spanId).toBe(error?.spanContext().spanId)
})

it("sends each breadcrumb once, and nothing without an error", async () => {
await init(BASE)
clickButton("first")
MapleBrowser.captureException(new Error("one"))
MapleBrowser.captureException(new Error("two"))
clickButton("never-reported")
await stop()

const clicks = exportedLogs.filter((log) => log.attributes["maple.breadcrumb.type"] === "click")
expect(clicks.map((log) => log.attributes["maple.breadcrumb.target"])).toEqual(["button#first"])
})

it("keeps nothing with breadcrumbs off", async () => {
await init({ ...BASE, breadcrumbs: false })
clickButton("save")
MapleBrowser.captureException(new Error("save failed"))
await stop()
expect(exportedLogs).toEqual([])
})
})

describe("logs.captureConsole", () => {
it("exports the chosen console levels as logs right away, and the rest only as breadcrumbs", async () => {
await init({ ...BASE, logs: { captureConsole: ["warn"] } })
console.warn("disk almost full")
console.info("not forwarded")
await stop()

expect(exportedLogs.map((log) => log.body)).toEqual(["disk almost full"])
expect(exportedLogs[0]?.severityText).toBe("WARN")
expect(exportedLogs[0]?.attributes["maple.log.source"]).toBe("console")
expect(exportedLogs[0]?.attributes["maple.breadcrumb.type"]).toBeUndefined()
})
})
15 changes: 15 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from "@maple/browser-session"
import type { ErrorFilterOptions } from "./error-filters"

export type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error"

/** Public configuration for `MapleBrowser.init`. */
export interface MapleBrowserConfig {
/**
Expand Down Expand Up @@ -85,6 +87,15 @@ export interface MapleBrowserConfig {
* log events. Default true.
*/
readonly webVitals?: boolean
/**
* Keep the last clicks, inputs, navigations and console lines in memory, and
* export them as logs linked to the error when one is recorded. Default true.
*/
readonly breadcrumbs?: boolean
readonly logs?: {
/** Console levels exported as OTel logs as they happen, e.g. `["warn", "error"]`. Default none. */
readonly captureConsole?: ReadonlyArray<ConsoleLevel>
}
/** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */
readonly errors?: ErrorFilterOptions
readonly replay?: {
Expand Down Expand Up @@ -154,6 +165,8 @@ export interface ResolvedConfig {
readonly tracingSampleRate: number
readonly errorFilters: ErrorFilterOptions
readonly webVitals: boolean
readonly breadcrumbs: boolean
readonly captureConsole: ReadonlyArray<ConsoleLevel>
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly maskAllInputs: boolean
Expand Down Expand Up @@ -220,6 +233,8 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
tracingSampleRate: resolveSampleRate("tracing.sampleRate", config.tracing?.sampleRate),
errorFilters: config.errors ?? {},
webVitals: config.webVitals ?? true,
breadcrumbs: config.breadcrumbs ?? true,
captureConsole: config.logs?.captureConsole ?? [],
replayEnabled: config.replay?.enabled ?? true,
replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate),
maskAllInputs: config.privacy?.maskAllInputs ?? true,
Expand Down
Loading
Loading