From 9b9bdd46248ed273ec0bd374910cb1e00a2799ab Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:18:55 +0200 Subject: [PATCH 1/2] feat(browser): breadcrumb trail on errors, console forwarding to logs An error arrived with no record of what led to it unless the session was sampled into replay, which is the only path that captured console output. - The last 50 clicks, inputs, navigations and console lines are kept in memory for every session. Nothing leaves the page until an error is recorded; then the trail is exported as OTel log records linked to the error's span (maple.browser.breadcrumb events, and console lines as ordinary logs at their severity). Each breadcrumb is sent once. - logs.captureConsole exports chosen console levels as logs as they happen, instead of only as breadcrumbs. - Clicks, inputs and navigations come from the session event sink, which gains a small onSessionEvent listener hook (on globalThis like the sink). Console lines come from the SDK's own capture, which runs whether or not replay records; @maple/browser-session exports its console capture for it. - All of it lives in the deferred chunk. errors.ts reaches it through a hook, so the eager bundle is unchanged. breadcrumbs: false turns it off. --- docs/browser-sdk.md | 18 +++ packages/browser-session/package.json | 3 +- .../browser-session/src/events/events-sink.ts | 27 ++++ packages/browser-session/src/index.ts | 1 + packages/browser/README.md | 6 + .../browser/src/breadcrumbs.browser.test.ts | 126 +++++++++++++++++ packages/browser/src/config.ts | 15 ++ packages/browser/src/deferred/breadcrumbs.ts | 131 ++++++++++++++++++ packages/browser/src/deferred/index.ts | 9 ++ packages/browser/src/errors.ts | 10 +- packages/browser/src/index.ts | 2 +- packages/browser/src/navigation.test.ts | 2 + packages/browser/src/tracing.browser.test.ts | 2 + 13 files changed, 349 insertions(+), 3 deletions(-) create mode 100644 packages/browser/src/breadcrumbs.browser.test.ts create mode 100644 packages/browser/src/deferred/breadcrumbs.ts 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/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 7f70009cd5..3ff22c7a1f 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, } From 68493b173144b2b07e72acfd88bd57e3bdf11ec7 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:14:10 +0200 Subject: [PATCH 2/2] fix(browser-session): stacked console captures survive each other's teardown Breadcrumbs and replay both wrap console.*. Tearing one down restored the method it had captured at install, so replay suspending on a hidden tab could remove the breadcrumb wrapper for the rest of the page. Teardown now only restores when its own wrapper is still the installed one, and a torn down wrapper left inside another's chain just forwards. --- .../src/replay/capture/console.test.ts | 24 +++++++++++++++++++ .../src/replay/capture/console.ts | 14 ++++++++--- 2 files changed, 35 insertions(+), 3 deletions(-) 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 } } }