diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index c8bdb809ac..d8d63263f3 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -56,6 +56,8 @@ Every field accepted by `MapleBrowser.init`: | `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). | | `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). | @@ -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 diff --git a/packages/browser-session/package.json b/packages/browser-session/package.json index b880d905cc..0ec42ba1aa 100644 --- a/packages/browser-session/package.json +++ b/packages/browser-session/package.json @@ -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", diff --git a/packages/browser-session/src/events/events-sink.ts b/packages/browser-session/src/events/events-sink.ts index 6b2006bcb1..11f3c51afc 100644 --- a/packages/browser-session/src/events/events-sink.ts +++ b/packages/browser-session/src/events/events-sink.ts @@ -30,6 +30,27 @@ export interface SessionEvent { attrs?: Record } +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 { + const global = globalThis as typeof globalThis & Record | 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 @@ -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 diff --git a/packages/browser-session/src/index.ts b/packages/browser-session/src/index.ts index 8d4911f179..e82fd1c676 100644 --- a/packages/browser-session/src/index.ts +++ b/packages/browser-session/src/index.ts @@ -14,6 +14,7 @@ export type { SessionEvent, SessionEventSink } from "./events/events-sink" export { clearPendingEvents, getActiveSink, + onSessionEvent, setActiveTraceIdProvider, startEventSink, } from "./events/events-sink" diff --git a/packages/browser-session/src/replay/capture/console.test.ts b/packages/browser-session/src/replay/capture/console.test.ts index a49cab853f..d19cb9de5a 100644 --- a/packages/browser-session/src/replay/capture/console.test.ts +++ b/packages/browser-session/src/replay/capture/console.test.ts @@ -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() + }) }) diff --git a/packages/browser-session/src/replay/capture/console.ts b/packages/browser-session/src/replay/capture/console.ts index b9d2628598..f2aacd583d 100644 --- a/packages/browser-session/src/replay/capture/console.ts +++ b/packages/browser-session/src/replay/capture/console.ts @@ -14,21 +14,29 @@ const MAX_ARRAY_ELEMENTS = 100 */ export function installConsoleCapture(emit: Emit): () => void { const original: Partial void>> = {} + const wrappers: Partial 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 } } } diff --git a/packages/browser/README.md b/packages/browser/README.md index 37601d0c50..b31e7f9055 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -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: diff --git a/packages/browser/src/breadcrumbs.browser.test.ts b/packages/browser/src/breadcrumbs.browser.test.ts new file mode 100644 index 0000000000..7972c70f81 --- /dev/null +++ b/packages/browser/src/breadcrumbs.browser.test.ts @@ -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 = (sink: T[]) => + class { + export(items: T[], callback: (result: { code: number }) => void): void { + sink.push(...items) + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + 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[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 | undefined +const stop = async (): Promise => { + 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 => { + 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() + }) +}) diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 109c9d3963..73cc830c56 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -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 { /** @@ -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 + } /** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */ readonly errors?: ErrorFilterOptions readonly replay?: { @@ -154,6 +165,8 @@ export interface ResolvedConfig { readonly tracingSampleRate: number readonly errorFilters: ErrorFilterOptions readonly webVitals: boolean + readonly breadcrumbs: boolean + readonly captureConsole: ReadonlyArray readonly replayEnabled: boolean readonly replaySampleRate: number readonly maskAllInputs: boolean @@ -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, diff --git a/packages/browser/src/deferred/breadcrumbs.ts b/packages/browser/src/deferred/breadcrumbs.ts new file mode 100644 index 0000000000..ca101e5054 --- /dev/null +++ b/packages/browser/src/deferred/breadcrumbs.ts @@ -0,0 +1,131 @@ +// The trail that led to an error: the last clicks, inputs, navigations and +// console lines, held in memory and exported only when an error is recorded, +// as OTel log records linked to the error's span. Also forwards chosen console +// levels as logs straight away (`logs.captureConsole`). +import { onSessionEvent, scrubUrl, type SessionEvent } from "@maple/browser-session" +import { installConsoleCapture } from "@maple/browser-session/console" +import type { SpanContext } from "@opentelemetry/api" +import { emitLog, Severity } from "../logs" + +import type { ConsoleLevel } from "../config" + +const MAX_CRUMBS = 50 + +interface Crumb { + readonly timestamp: number + readonly type: SessionEvent["type"] + readonly level?: string | undefined + readonly message?: string | undefined + readonly target?: string | undefined + readonly url?: string | undefined +} + +const severityOf = (level: string | undefined): { number: number; text: keyof typeof Severity } => { + if (level === "error") return { number: Severity.ERROR, text: "ERROR" } + if (level === "warn") return { number: Severity.WARN, text: "WARN" } + if (level === "debug") return { number: Severity.DEBUG, text: "DEBUG" } + return { number: Severity.INFO, text: "INFO" } +} + +let crumbs: Crumb[] = [] +let active = false + +function push(crumb: Crumb): void { + crumbs.push(crumb) + if (crumbs.length > MAX_CRUMBS) crumbs.shift() +} + +function emitConsole( + level: string | undefined, + message: string, + timestamp: number, + spanContext?: SpanContext, +): void { + const severity = severityOf(level) + emitLog({ + severityNumber: severity.number, + severityText: severity.text, + body: message, + timestamp, + spanContext, + attributes: { + "maple.log.source": "console", + ...(spanContext ? { "maple.breadcrumb.type": "console" } : undefined), + }, + }) +} + +export interface BreadcrumbOptions { + /** Keep a trail for errors. */ + readonly breadcrumbs: boolean + /** Console levels exported as logs as they happen, instead of only as breadcrumbs. */ + readonly captureConsole: ReadonlyArray +} + +/** Start collecting. Returns a stop. */ +export function startBreadcrumbs(options: BreadcrumbOptions): () => void { + if (!options.breadcrumbs && options.captureConsole.length === 0) return () => {} + active = true + const forwarded = new Set(options.captureConsole) + const stopEvents = options.breadcrumbs + ? onSessionEvent((ev) => { + // Console comes from this module's own capture, which runs whether or not replay records. + if (!active || (ev.type !== "click" && ev.type !== "input" && ev.type !== "navigation")) + return + push({ + timestamp: ev.timestamp ?? Date.now(), + type: ev.type, + target: ev.targetSelector, + message: ev.targetText, + url: ev.url ?? location.href, + }) + }) + : () => {} + const stopConsole = installConsoleCapture((ev) => { + if (!active || ev.message === undefined) return + const timestamp = Date.now() + if (ev.level !== undefined && forwarded.has(ev.level)) { + emitConsole(ev.level, ev.message, timestamp) + return + } + if (options.breadcrumbs) push({ timestamp, type: "console", level: ev.level, message: ev.message }) + }) + return () => { + active = false + crumbs = [] + stopEvents() + stopConsole() + } +} + +/** Export the trail so far, linked to the error's span, and start a new one. */ +export function flushBreadcrumbs(spanContext: SpanContext): void { + if (crumbs.length === 0) return + const trail = crumbs + crumbs = [] + for (const crumb of trail) { + if (crumb.type === "console") { + emitConsole(crumb.level, crumb.message ?? "", crumb.timestamp, spanContext) + continue + } + emitLog({ + eventName: "maple.browser.breadcrumb", + severityNumber: Severity.INFO, + severityText: "INFO", + body: [crumb.type, crumb.target, crumb.message].filter(Boolean).join(" "), + timestamp: crumb.timestamp, + spanContext, + attributes: { + "maple.breadcrumb.type": crumb.type, + ...(crumb.target ? { "maple.breadcrumb.target": crumb.target } : undefined), + ...(crumb.url ? { "url.full": scrubUrl(crumb.url) } : undefined), + }, + }) + } +} + +/** Test seam. */ +export function resetBreadcrumbsForTests(): void { + crumbs = [] + active = false +} diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts index 98646e326b..3ddeb818ca 100644 --- a/packages/browser/src/deferred/index.ts +++ b/packages/browser/src/deferred/index.ts @@ -2,7 +2,9 @@ // behind this chunk, so it stays off the eager bundle every page load pays for. import type { SpanContext } from "@opentelemetry/api" import type { ResolvedConfig } from "../config" +import { setErrorRecordedHook } from "../errors" import { onDocumentPageload } from "../navigation" +import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs" import { recordDocumentTiming } from "./document-timing" import { startLogs } from "./logs" import { startWebVitals } from "./web-vitals" @@ -15,11 +17,18 @@ export function startDeferred(config: ResolvedConfig): () => Promise { }) // Before the logs pipeline, so vitals reported on page hide are emitted before its flush listener runs. const stopVitals = config.webVitals ? startWebVitals(() => pageload) : () => {} + const stopBreadcrumbs = startBreadcrumbs({ + breadcrumbs: config.breadcrumbs, + captureConsole: config.captureConsole, + }) + setErrorRecordedHook(flushBreadcrumbs) const stops = [ startLogs(config), async () => { stopVitals() onDocumentPageload(undefined) + setErrorRecordedHook(undefined) + stopBreadcrumbs() }, ] return async () => { diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index ee6cb06004..1ba205636c 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -10,7 +10,7 @@ // Error. That is the shape `error_events_mv` fingerprints on, so these arrive in // 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 { context, type Span, type SpanContext, SpanKind, SpanStatusCode } from "@opentelemetry/api" import { exceptionOf } from "./error-causes" import { type ErrorSource, shouldCapture } from "./error-filters" import { keepContext } from "./sampling" @@ -48,6 +48,13 @@ const asError = (value: unknown): Error => { */ let reported = new WeakSet() +/** Set by the deferred chunk, which exports the error's breadcrumb trail. */ +let onErrorRecorded: ((spanContext: SpanContext) => void) | undefined + +export function setErrorRecordedHook(hook: ((spanContext: SpanContext) => void) | undefined): void { + onErrorRecorded = hook +} + /** Whether this exact error object was already recorded. */ const alreadyReported = (error: unknown): boolean => typeof error === "object" && error !== null && reported.has(error) @@ -70,6 +77,7 @@ export function recordFailure(span: Span, error: unknown): void { if (!alreadyReported(error)) { if (span.isRecording() && typeof error === "object" && error !== null) reported.add(error) span.recordException(exceptionOf(normalized)) + if (span.isRecording()) onErrorRecorded?.(span.spanContext()) } span.setStatus({ code: SpanStatusCode.ERROR, message: normalized.message }) } diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 6f94eb34a6..e46fe89546 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -12,7 +12,7 @@ export type { TrackProps, TraitValue, } from "@maple/browser-session" -export type { MapleBrowserConfig } from "./config" +export type { ConsoleLevel, MapleBrowserConfig } from "./config" export type { ErrorFilterHint, ErrorFilterOptions, ErrorSource } from "./error-filters" export type { CaptureExceptionOptions } from "./errors" export type { MapleBrowserHandle } from "./init" diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index 929f947668..f2cb13ae9c 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -48,6 +48,8 @@ const CONFIG = { tracingInstrumentXhr: false, errorFilters: {}, webVitals: false, + breadcrumbs: false, + captureConsole: [], sanitizeUrl: undefined, } diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index 6ff37bef52..5acf5ddaee 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -57,6 +57,8 @@ const CONFIG = { tracingInstrumentXhr: false, errorFilters: {}, webVitals: false, + breadcrumbs: false, + captureConsole: [], sanitizeUrl: undefined, }