From 014525818679564da9bd5d9025b76984474928e9 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 01:17:12 +0200 Subject: [PATCH 1/4] fix(browser): address review findings in the browser SDK and session replay Offline queue - With requireConsent, batches from an earlier page were deleted on the next load: consent is re-granted every load, so every batch predated it. Batches are now dropped only if consent was withdrawn after they were stored (a revoke is persisted to localStorage, so it holds across tabs). - indexedDB.open throwing synchronously (sandboxed iframes, data: pages) no longer kills the deferred chunk. - shutdown() flushes tracing and logs before closing the queue, and the queue closes only after in-flight writes, so a final failed batch is kept. Errors - A fetch that never got a response (offline, DNS, CORS) is now an error span with error.type set to what failed; aborts are not. Network-failure spans get the same "METHOD url -> type" error.message as status errors. - captureException and the global handlers only use Maple's own provider, never a host app's global one, and skip work while consent is withheld. An error recorded without consent is no longer marked reported, so it is still captured after a re-grant. - traced() applies the error filters, as the global handlers do. - Building the Caused-by chain can no longer throw (null-prototype or throwing toString causes, engines without AggregateError). Signals - Consecutive input breadcrumbs on one field collapse into one, so typing no longer evicts the clicks and navigations before an error. - Slow interactions are no longer all dropped where interactionId is missing; jank spans are parented to the navigation only if it had already started. - CSP violations are also read from the DOM event, deduped with ReportingObserver, since not every observer delivers them. - XHR response headers are read once from getAllResponseHeaders(), so an unexposed cross-origin header no longer logs a console error per request. - x-api-key joins the headers that are never recorded. - React Router: a same-path loading state (search change, revalidation) no longer starts a navigation span. Session replay - Network bodies: patterns match the full URL; text/event-stream is never read; body reads give up after 5s; request bodies are kept only with maskAllInputs off, since a form POST carries what was typed. - Buffer mode takes its 30s checkout snapshot only while the page is visible and has changed, instead of on a fixed timer. - An unknown replayTrigger no longer invalidates the whole session record, and a session minted by idle rotation keeps the page's buffer decision. Docs updated for each behaviour change and for the router and XHR propagation mismatches. --- .../docs/session-replay/browser-sdk.md | 4 +- docs/browser-sdk.md | 33 ++++++---- .../browser-session/src/platform/transport.ts | 2 + .../replay/capture/network.browser.test.ts | 38 ++++++++++++ .../src/replay/capture/network.ts | 52 +++++++++++++--- packages/browser-session/src/replay/events.ts | 8 ++- .../browser-session/src/replay/record.test.ts | 14 +++++ packages/browser-session/src/replay/record.ts | 18 +++++- .../browser-session/src/session/lifecycle.ts | 4 +- .../src/session/replay-session.ts | 1 + .../src/session/session.test.ts | 22 +++++++ .../browser-session/src/session/session.ts | 14 +++-- packages/browser/README.md | 5 +- .../browser/src/breadcrumbs.browser.test.ts | 19 ++++++ packages/browser/src/deferred/breadcrumbs.ts | 6 ++ packages/browser/src/deferred/index.ts | 25 ++++---- .../src/deferred/offline.browser.test.ts | 39 +++++++++++- packages/browser/src/deferred/offline.ts | 60 +++++++++++++------ packages/browser/src/deferred/perf.ts | 7 ++- packages/browser/src/deferred/reports.ts | 33 +++++----- packages/browser/src/error-causes.test.ts | 11 ++++ packages/browser/src/error-causes.ts | 15 ++++- packages/browser/src/errors.browser.test.ts | 20 ++++++- packages/browser/src/errors.ts | 28 +++++---- packages/browser/src/http-headers.ts | 18 +++++- packages/browser/src/http-status.test.ts | 12 ++++ packages/browser/src/http-status.ts | 17 +++++- packages/browser/src/init.ts | 5 +- packages/browser/src/navigation.ts | 21 +++++-- packages/browser/src/react.browser.test.ts | 15 +++++ packages/browser/src/react.ts | 3 +- packages/browser/src/tracing.browser.test.ts | 17 ++++++ packages/browser/src/tracing.ts | 31 ++++++---- 33 files changed, 494 insertions(+), 123 deletions(-) diff --git a/apps/landing/src/content/docs/session-replay/browser-sdk.md b/apps/landing/src/content/docs/session-replay/browser-sdk.md index 020fe71f9..97b2459e1 100644 --- a/apps/landing/src/content/docs/session-replay/browser-sdk.md +++ b/apps/landing/src/content/docs/session-replay/browser-sdk.md @@ -65,7 +65,7 @@ Every field accepted by `MapleBrowser.init`: | `tracing.enabled` | `boolean` | `true` | Enable OpenTelemetry browser tracing. | | `tracing.instrumentFetch` | `boolean` | `true` | Create spans for `fetch()` calls. Set `false` when another tracer (such as the Effect client SDK) already instruments requests, to avoid duplicate network spans. | | `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled promise rejections as error spans. Turn it off only when another tool owns the page's global error handlers. | -| `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` requests carry the `traceparent` header. See [Connect browser and backend traces](#connect-browser-and-backend-traces). | +| `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` and XHR requests carry the `traceparent` header. See [Connect browser and backend traces](#connect-browser-and-backend-traces). | | `replay.enabled` | `boolean` | `true` | Enable session recording. | | `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. See [Sampling](#sampling). | | `privacy.maskAllInputs` | `boolean` | `true` | Mask all `` values in the recording. | @@ -106,7 +106,7 @@ Every span and replay event the SDK emits carries one **`session.id`** (a `crypt The session is stored in `sessionStorage` under the key `maple.session`, so it **survives reloads within a tab**. `sessionStorage` is per tab, so **each tab or window gets its own session**. When `sessionStorage` is unavailable (for example in some private-browsing modes), the SDK keeps the session in memory for the life of the page. -Client-side route changes in a single-page app do **not** start a new session. The SDK tracks no router events. Session boundaries are purely time-based. +Client-side route changes in a single-page app do **not** start a new session. Session boundaries are purely time-based. ### Rotation diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 3e0829a86..c46c367e9 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -53,8 +53,8 @@ Every field accepted by `MapleBrowser.init`: | `tracing.instrumentFetch` | `boolean` | `true` | Auto-instrument `fetch()` to create network spans. Set `false` when another tracer (e.g. the Effect client SDK) already instruments requests. Its spans feed the session through the published sink, and turning this off avoids duplicate network spans. | | `tracing.instrumentXhr` | `boolean` | `true` | Auto-instrument `XMLHttpRequest` (axios and older clients) like `fetch`. | | `tracing.captureErrors` | `boolean` | `true` | Record uncaught errors and unhandled rejections as error spans. See [Errors](#errors). | -| `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). | +| `tracing.propagateTraceHeaderCorsUrls` | `Array` | `[]` | Cross-origin URLs whose `fetch()` and XHR 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; reported errors 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"]`. | @@ -123,8 +123,8 @@ session**. Sessions are never shared across them. When `sessionStorage` is unava some private-browsing modes), the SDK falls back to an in-memory record for the life of the page. -SPA route changes do **not** start a new session. The SDK tracks no router events, so -client-side navigation stays within the same session. Session boundaries are purely +SPA route changes do **not** start a new session: navigation spans (see +[React integration](#react-integration)) stay within it. Session boundaries are purely time-based (see below). ### Rotation @@ -308,7 +308,8 @@ MapleBrowser.init({ 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. +(no response at all: offline, DNS, CORS, a timeout) are always errors, with `error.type` set to +what failed (`TypeError` for `fetch`, `error` or `timeout` for XHR). An aborted request is not. ### Breadcrumbs @@ -467,7 +468,7 @@ To record only a fraction of sessions, set `replay.sampleRate` between `0` and ` `replay.onErrorSampleRate` covers the sessions `replay.sampleRate` leaves out. Those sessions run the recorder into memory only, keeping roughly the last minute (the segments since the -second-to-last full snapshot, taken every 30s). Nothing is uploaded. When an error is recorded (an +second-to-last full snapshot, taken every 30s while the page is visible and changing). Nothing is uploaded. When an error is recorded (an uncaught error, an unhandled rejection or `captureException`, after [filters](#filtering-errors)), the buffered minute is uploaded and the rest of the session is recorded normally, including its later page loads. The session is marked `maple.session.replay_trigger: "error"`, and its replay starts @@ -485,7 +486,9 @@ memory. `tracing.sampleRate` does the same for traces. The decision is made once per session (a hash of `session.id`), so a sampled session keeps every one of its traces and its replay never links to a -dropped one. Spans that record an error are always exported, whatever the rate. +dropped one. Errors reported as their own spans (uncaught errors, unhandled rejections and +`captureException`) are always exported, whatever the rate; request spans of an unsampled session +are not, including ones `errors.captureHttpStatus` would have marked. ```ts MapleBrowser.init({ @@ -548,10 +551,12 @@ replay: { } ``` -Only text and JSON bodies are kept, each cut to `maxLength` characters (at most and by default 1,000: ingest stores up to 1 KB per body). The response is read from a -clone in the background, only as far as `maxLength`, so your code gets it untouched and unwaited. Nothing is captured with -`privacy.maskAllText`. Bodies can hold personal data: list only endpoints whose payloads you are -allowed to record. +Patterns match the full URL, so a relative `fetch("/api/checkout")` is matched as +`https://your.app/api/checkout`. Only text and JSON bodies are kept, each cut to `maxLength` characters (at most and by default 1,000: ingest stores up to 1 KB per body). The response is read from a +clone in the background, only as far as `maxLength` and for at most 5 seconds, so your code gets it untouched and unwaited; +event streams (`text/event-stream`) are never read. Nothing is captured with `privacy.maskAllText`, and +request bodies only with `privacy.maskAllInputs: false`, since a form POST carries what was typed. Bodies +can hold personal data: list only endpoints whose payloads you are allowed to record. ### Canvas @@ -565,7 +570,8 @@ The OTLP exporters already retry a failed export a few times (about 10 seconds i `transport: { offline: true }`, a batch that still fails (the browser is offline, or ingest is down) is kept in IndexedDB, as the same OTLP JSON the exporter sends, and sent again when the browser fires `online` and on the next page load. Batches older than 24 hours are dropped, and at -most 100 are kept. Revoking consent clears the queue. Where IndexedDB is unavailable (some private +most 100 are kept. Revoking consent clears the queue, in every tab; with `privacy.requireConsent`, +batches from an earlier page are still sent once consent is granted again, unless it was revoked in between. Where IndexedDB is unavailable (some private windows), nothing is kept. ## Framework examples @@ -644,7 +650,8 @@ createRoot(document.getElementById("root")!, { the leaf route's full path (`navigate /projects/$projectId`). Search-only changes are not navigations. -Both adapters return an unsubscribe. Don't also call `startNavigation`/`endNavigation` yourself. +Both adapters return an unsubscribe. Attach them after `MapleBrowser.init`, or the page load is +missed, and don't also call `startNavigation`/`endNavigation` yourself. ## Notes diff --git a/packages/browser-session/src/platform/transport.ts b/packages/browser-session/src/platform/transport.ts index 67e222cce..d113f5634 100644 --- a/packages/browser-session/src/platform/transport.ts +++ b/packages/browser-session/src/platform/transport.ts @@ -30,6 +30,8 @@ export interface NetworkBodyOptions { readonly urls: ReadonlyArray /** Each body is cut to this many characters. */ readonly maxLength: number + /** Keep request bodies too. Off while inputs are masked: a form POST carries what was typed. */ + readonly requestBodies?: boolean | undefined } /** diff --git a/packages/browser-session/src/replay/capture/network.browser.test.ts b/packages/browser-session/src/replay/capture/network.browser.test.ts index 7872e90dc..9341b8cab 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -84,6 +84,44 @@ describe("installNetworkCapture", () => { } }) + it("matches the full URL, never reads event streams, and keeps request bodies only when asked", async () => { + const realFetch = window.fetch + let streamed = false + window.fetch = async (input) => + String(input).includes("events") + ? new Response( + new ReadableStream({ + start() { + streamed = true + }, + }), + { headers: { "content-type": "text/event-stream" } }, + ) + : new Response("ok", { headers: { "content-type": "text/plain" } }) + try { + const events: SessionEvent[] = [] + uninstall = installNetworkCapture( + (event) => events.push(event), + () => false, + { urls: [new RegExp(`^${location.origin}/api/`)], maxLength: 100, requestBodies: false }, + ) + await fetch("/api/login", { method: "POST", body: "password=hunter2" }) + await fetch("/api/events") + await vi.waitFor(() => expect(events.filter((event) => event.type === "network")).toHaveLength(2)) + + const byUrl = (part: string) => events.find((event) => event.net?.url.includes(part)) + const login = byUrl("login") + const stream = byUrl("events") + expect(login?.attrs).toEqual({ "response.body": "ok" }) + expect(stream?.attrs).toBeUndefined() + expect(streamed).toBe(true) + } finally { + uninstall?.() + uninstall = undefined + window.fetch = realFetch + } + }) + it("reads only as much of a large body as it keeps", async () => { const realFetch = window.fetch let pulled = 0 diff --git a/packages/browser-session/src/replay/capture/network.ts b/packages/browser-session/src/replay/capture/network.ts index ab716e017..916056482 100644 --- a/packages/browser-session/src/replay/capture/network.ts +++ b/packages/browser-session/src/replay/capture/network.ts @@ -4,6 +4,22 @@ import type { NetworkBodyOptions } from "../../platform/transport" /** Only text is worth keeping; a body that is an image or a stream is not read. */ const TEXT_CONTENT = /^(text\/|application\/(json|xml|x-www-form-urlencoded|[\w.+-]+\+(json|xml)))/i +/** Text, but never finished: reading a clone would hold the connection open after the app cancels. */ +const STREAMING_CONTENT = /^text\/event-stream/i +/** A slow body is given up on rather than kept in memory for as long as it trickles. */ +const BODY_READ_TIMEOUT_MS = 5_000 + +const isReadableText = (contentType: string): boolean => + TEXT_CONTENT.test(contentType) && !STREAMING_CONTENT.test(contentType) + +/** Patterns match the full URL, as documented: a relative `fetch("/api")` is resolved against the page. */ +const absoluteUrl = (url: string): string => { + try { + return new URL(url, location.href).href + } catch { + return url + } +} const matchesUrl = (url: string, patterns: ReadonlyArray): boolean => patterns.some((pattern) => { @@ -27,12 +43,15 @@ export function installNetworkCapture( ignoreUrl: (url: string) => boolean, bodies?: NetworkBodyOptions, ): () => void { - const wantsBody = (url: string): boolean => bodies !== undefined && matchesUrl(url, bodies.urls) + const wantsBody = (url: string): boolean => + bodies !== undefined && matchesUrl(absoluteUrl(url), bodies.urls) const bodyAttrs = ( request: string | undefined, response: string | undefined, ): Record => ({ - ...(request && bodies ? { "request.body": cut(request, bodies.maxLength) } : undefined), + ...(request && bodies?.requestBodies !== false + ? { "request.body": cut(request, bodies?.maxLength ?? 0) } + : undefined), ...(response && bodies ? { "response.body": cut(response, bodies.maxLength) } : undefined), }) const origFetch = typeof window !== "undefined" ? window.fetch : undefined @@ -59,7 +78,7 @@ export function installNetworkCapture( const done = performance.now() const contentType = res.headers.get("content-type") ?? "" void ( - TEXT_CONTENT.test(contentType) + isReadableText(contentType) ? readPrefix(res.clone(), bodies?.maxLength ?? 0) : Promise.resolve(undefined) ) @@ -154,18 +173,37 @@ async function readPrefix(response: Response, maxLength: number): Promise 0 ? await readWithin(reader, remaining) : undefined + if (!chunk) { + // Timed out: keep what arrived and release the connection. + void reader.cancel().catch(() => {}) + return text || undefined + } + if (chunk.done) return text + decoder.decode() + text += decoder.decode(chunk.value, { stream: true }) } void reader.cancel().catch(() => {}) return text } +/** One read, or `undefined` if nothing arrives within `ms`. */ +function readWithin( + reader: ReadableStreamDefaultReader, + ms: number, +): Promise | undefined> { + let timer: ReturnType | undefined + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(undefined), ms) + }) + return Promise.race([reader.read(), timeout]).finally(() => clearTimeout(timer)) +} + /** A text or JSON XHR response, as text; the browser already holds it, so this only slices. */ function xhrResponseText(xhr: XMLHttpRequest): string | undefined { - if (!TEXT_CONTENT.test(xhr.getResponseHeader("content-type") ?? "")) return undefined + if (!isReadableText(xhr.getResponseHeader("content-type") ?? "")) return undefined if (xhr.responseType === "" || xhr.responseType === "text") return xhr.responseText if (xhr.responseType === "json") { try { diff --git a/packages/browser-session/src/replay/events.ts b/packages/browser-session/src/replay/events.ts index cd0aefd58..3573d49e0 100644 --- a/packages/browser-session/src/replay/events.ts +++ b/packages/browser-session/src/replay/events.ts @@ -33,7 +33,13 @@ export function startEventCapture(config: IngestConfig, sessionId: string): Even const uninstall = [ installConsoleCapture(emit), - installNetworkCapture(emit, sink.ignoreUrl, config.maskAllText ? undefined : config.networkBodies), + installNetworkCapture( + emit, + sink.ignoreUrl, + config.maskAllText || !config.networkBodies + ? undefined + : { ...config.networkBodies, requestBodies: !config.maskAllInputs }, + ), ] return { diff --git a/packages/browser-session/src/replay/record.test.ts b/packages/browser-session/src/replay/record.test.ts index 475bc3611..fa561f04b 100644 --- a/packages/browser-session/src/replay/record.test.ts +++ b/packages/browser-session/src/replay/record.test.ts @@ -263,6 +263,20 @@ describe("startBufferedRecording", () => { expect(posted).toEqual([]) expect(stopFn).toHaveBeenCalled() }) + + it("takes a checkout snapshot only when the page changed since the last one", () => { + vi.useFakeTimers() + takeFullSnapshot.mockClear() + const recorder = startBufferedRecording(CONFIG, "session-1") + snapshot(1_000) + vi.advanceTimersByTime(30_000) + expect(takeFullSnapshot).not.toHaveBeenCalled() + emitRef!(incremental(1_500)) + vi.advanceTimersByTime(30_000) + expect(takeFullSnapshot).toHaveBeenCalledWith(true) + recorder.stop() + vi.useRealTimers() + }) }) describe("canvas capture", () => { diff --git a/packages/browser-session/src/replay/record.ts b/packages/browser-session/src/replay/record.ts index 567ae9db6..54614771f 100644 --- a/packages/browser-session/src/replay/record.ts +++ b/packages/browser-session/src/replay/record.ts @@ -273,7 +273,10 @@ function canvasOptions(config: IngestConfig) { } } -/** Buffer mode checks out often, so the retained window stays near a minute. */ +/** + * Buffer mode checks out often, so the retained window stays near a minute. A + * checkout is a full DOM snapshot, so it is skipped while hidden or when nothing changed. + */ const BUFFER_CHECKOUT_MS = 30_000 interface Segment { @@ -300,6 +303,8 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): let bytes = 0 let clickCount = 0 let stopped = false + /** Incremental events since the last snapshot: an idle page needs no new one. */ + let changedSinceSnapshot = 0 const stopRecord = record({ emit: (event: unknown) => { @@ -325,6 +330,9 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): if (e.type === META) { segments.push({ parts: [], bytes: 0, first: e.timestamp, last: e.timestamp }) while (segments.length > 2) bytes -= segments.shift()?.bytes ?? 0 + changedSinceSnapshot = 0 + } else if (e.type === INCREMENTAL) { + changedSinceSnapshot++ } const segment = segments.at(-1) // Nothing to play back before the first snapshot. @@ -338,10 +346,15 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): maskAllInputs: config.maskAllInputs, blockSelector: BLOCK_SELECTOR, ...(config.maskAllText ? { maskTextSelector: "*" } : undefined), - checkoutEveryNms: BUFFER_CHECKOUT_MS, ...canvasOptions(config), }) + const checkoutTimer = setInterval(() => { + if (stopped || changedSinceSnapshot === 0) return + if (typeof document !== "undefined" && document.visibilityState === "hidden") return + record.takeFullSnapshot(true) + }, BUFFER_CHECKOUT_MS) + return { drain: async (keepalive = false) => { const pending = segments @@ -368,6 +381,7 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): }, stop: () => { stopped = true + clearInterval(checkoutTimer) segments = [] bytes = 0 stopRecord?.() diff --git a/packages/browser-session/src/session/lifecycle.ts b/packages/browser-session/src/session/lifecycle.ts index 9b8adb648..3b5e068f6 100644 --- a/packages/browser-session/src/session/lifecycle.ts +++ b/packages/browser-session/src/session/lifecycle.ts @@ -64,6 +64,8 @@ export interface SessionLifecycleHooks { * A function when it can change mid-run: a buffered session becomes recorded when an error happens. */ readonly recorded: boolean | (() => boolean) + /** Whether this page buffers replay for errors, so a session minted by rotation keeps that decision. */ + readonly buffered?: (() => boolean) | undefined /** POST one metadata row. Best-effort — must never throw. */ readonly post: (row: Record, keepalive: boolean) => void /** @@ -223,7 +225,7 @@ export function startSessionLifecycle( const record = liveRecord() // A session minted by idle rotation mid-page has no sampling decision yet; // it takes this page's mode so its later loads agree with it. - adoptReplayDecision(record.id, isRecorded()) + adoptReplayDecision(record.id, isRecorded(), hooks.buffered?.()) rebaseCounts(record) hooks.onStart?.(record) post("active", false) diff --git a/packages/browser-session/src/session/replay-session.ts b/packages/browser-session/src/session/replay-session.ts index e6238fdec..8a0d168d4 100644 --- a/packages/browser-session/src/session/replay-session.ts +++ b/packages/browser-session/src/session/replay-session.ts @@ -100,6 +100,7 @@ export function startReplaySession(options: ReplaySessionOptions): ReplaySession { // Recorded from the start, or from the moment a buffered session is triggered. recorded: () => triggered, + buffered: () => buffering, post: (row, keepalive) => { void postSessionMeta(engineConfig, row, keepalive) }, diff --git a/packages/browser-session/src/session/session.test.ts b/packages/browser-session/src/session/session.test.ts index cca32f7c7..f56854d49 100644 --- a/packages/browser-session/src/session/session.test.ts +++ b/packages/browser-session/src/session/session.test.ts @@ -455,6 +455,28 @@ describe("claimReplayMode", () => { expect(claimReplayMode(0, 1)).toBe("record") expect(peekSession()?.replayTrigger).toBe("error") }) + + it("keeps a record whose trigger a newer SDK wrote, instead of starting a new session", () => { + const now = Date.now() + storage.setItem( + STORAGE_KEY, + JSON.stringify({ + id: "newer", + startedAt: now, + lastActivityAt: now, + chunkSeq: 0, + replayTrigger: "manual", + }), + ) + expect(getSession().id).toBe("newer") + expect(peekSession()?.replayTrigger).toBeUndefined() + }) + + it("pins a rotated session to buffering when the page buffers", () => { + const session = getSession() + adoptReplayDecision(session.id, false, true) + expect(claimReplayMode(0, 0)).toBe("buffer") + }) }) describe("quota exhaustion", () => { diff --git a/packages/browser-session/src/session/session.ts b/packages/browser-session/src/session/session.ts index 8446defb2..eef4720c1 100644 --- a/packages/browser-session/src/session/session.ts +++ b/packages/browser-session/src/session/session.ts @@ -153,10 +153,8 @@ function parseSessionRecord(raw: string): SessionRecord | undefined { if (typeof value.replayBuffered !== "boolean") return undefined record.replayBuffered = value.replayBuffered } - if (value.replayTrigger !== undefined) { - if (value.replayTrigger !== "error") return undefined - record.replayTrigger = value.replayTrigger - } + // An unknown trigger (from a newer SDK) is dropped, not fatal: rejecting the record would split the session. + if (value.replayTrigger === "error") record.replayTrigger = value.replayTrigger if (value.utm !== undefined) { if (!isStringRecord(value.utm)) return undefined record.utm = value.utm @@ -658,8 +656,12 @@ export function markReplayTriggered(sessionId: string): void { * minted by idle rotation mid-page inherits that page's mode rather than * rolling again, and later loads of it honour the same answer. */ -export function adoptReplayDecision(sessionId: string, recorded: boolean): void { +export function adoptReplayDecision(sessionId: string, recorded: boolean, buffered?: boolean): void { const record = readRecord() if (!record || record.id !== sessionId || record.replaySampled !== undefined) return - writeRecord({ ...record, replaySampled: recorded }) + writeRecord({ + ...record, + replaySampled: recorded, + ...(buffered !== undefined && !recorded ? { replayBuffered: buffered } : undefined), + }) } diff --git a/packages/browser/README.md b/packages/browser/README.md index c1596c640..1343968b8 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -154,7 +154,8 @@ an error is recorded. ## Trace sampling `tracing: { sampleRate: 0.25 }` exports the traces of ~25% of sessions. The decision is per -session, so a sampled session keeps all of its traces. Error spans are always exported. +session, so a sampled session keeps all of its traces. Reported errors (uncaught errors, unhandled +rejections, `captureException`) are always exported. ## Regions @@ -165,7 +166,7 @@ explicit `endpoint` (a proxy, or self-hosted ingest) always wins over `region`. ## Tracing across origins -`fetch` spans carry the W3C `traceparent` header to same-origin requests only. +`fetch` and XHR spans carry the W3C `traceparent` header to same-origin requests only. When your API lives on another origin, list it so browser and backend spans join one trace, and allow the `traceparent` header in the API's CORS policy: diff --git a/packages/browser/src/breadcrumbs.browser.test.ts b/packages/browser/src/breadcrumbs.browser.test.ts index 7972c70f8..ca9765bf6 100644 --- a/packages/browser/src/breadcrumbs.browser.test.ts +++ b/packages/browser/src/breadcrumbs.browser.test.ts @@ -102,6 +102,25 @@ describe("breadcrumbs", () => { expect(clicks.map((log) => log.attributes["maple.breadcrumb.target"])).toEqual(["button#first"]) }) + it("keeps one crumb per field while typing, so keystrokes don't evict the trail", async () => { + await init(BASE) + clickButton("compose") + const field = document.createElement("textarea") + field.id = "message" + document.body.append(field) + for (const text of ["h", "he", "hel", "hell", "hello"]) { + field.value = text + field.dispatchEvent(new Event("input", { bubbles: true })) + } + MapleBrowser.captureException(new Error("send failed")) + await stop() + + const types = exportedLogs + .map((log) => log.attributes["maple.breadcrumb.type"]) + .filter((type) => type === "click" || type === "input") + expect(types).toEqual(["click", "input"]) + }) + it("keeps nothing with breadcrumbs off", async () => { await init({ ...BASE, breadcrumbs: false }) clickButton("save") diff --git a/packages/browser/src/deferred/breadcrumbs.ts b/packages/browser/src/deferred/breadcrumbs.ts index ca101e505..d47517287 100644 --- a/packages/browser/src/deferred/breadcrumbs.ts +++ b/packages/browser/src/deferred/breadcrumbs.ts @@ -31,6 +31,12 @@ let crumbs: Crumb[] = [] let active = false function push(crumb: Crumb): void { + // Typing is one `input` per keystroke: keep one crumb per field, or a message evicts the whole trail. + const last = crumbs.at(-1) + if (crumb.type === "input" && last?.type === "input" && last.target === crumb.target) { + crumbs[crumbs.length - 1] = crumb + return + } crumbs.push(crumb) if (crumbs.length > MAX_CRUMBS) crumbs.shift() } diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts index fd8d6918f..53cdebf23 100644 --- a/packages/browser/src/deferred/index.ts +++ b/packages/browser/src/deferred/index.ts @@ -30,20 +30,17 @@ export function startDeferred(config: ResolvedConfig): () => Promise { const stopPerf = startPerf({ longFrames: config.longFrames, slowInteractions: config.slowInteractions }) const offline = config.offlineQueue ? startOfflineQueue(config) : undefined attachSpanStash(offline?.stashSpans) - const stops = [ - startLogs(config, offline?.stashLogs), - async () => { - stopVitals() - onDocumentPageload(undefined) - stopErrorListener() - stopBreadcrumbs() - stopReports() - attachSpanStash(undefined) - offline?.stop() - stopPerf() - }, - ] + const stopLogs = startLogs(config, offline?.stashLogs) return async () => { - await Promise.all(stops.map((stop) => stop())) + stopVitals() + onDocumentPageload(undefined) + stopErrorListener() + stopBreadcrumbs() + stopReports() + stopPerf() + // The logs' last flush may fail into the offline queue, so it closes after them. + await stopLogs() + attachSpanStash(undefined) + offline?.stop() } } diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/browser/src/deferred/offline.browser.test.ts index d0bd74d38..a6416df62 100644 --- a/packages/browser/src/deferred/offline.browser.test.ts +++ b/packages/browser/src/deferred/offline.browser.test.ts @@ -41,6 +41,7 @@ const storedCount = async (): Promise => { let stop: (() => void) | undefined beforeEach(async () => { + localStorage.removeItem("maple-offline-revoked-at") await new Promise((resolve) => { const request = indexedDB.deleteDatabase("maple-offline") request.onsuccess = request.onerror = request.onblocked = () => resolve(undefined) @@ -129,7 +130,38 @@ describe("offline queue across tabs", () => { }) describe("offline queue and consent", () => { - it("drops batches captured before the current consent grant instead of sending them", async () => { + it("sends an earlier page's batches once consent is granted on this one", async () => { + const posts: string[] = [] + let online = false + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (!online) throw new TypeError("Failed to fetch") + posts.push(url) + return new Response("{}") + }), + ) + configurePrivacy({ requireConsent: true }) + setConsent(true) + const first = startOfflineQueue(CONFIG) + first.stashSpans(finishedSpans("offline")) + await vi.waitFor(async () => expect(await storedCount()).toBe(1)) + first.stop() + + // The next page load: consent is granted afresh, which is not a revoke. + resetConsentForTests() + configurePrivacy({ requireConsent: true }) + await new Promise((resolve) => setTimeout(resolve, 5)) + setConsent(true) + online = true + const second = startOfflineQueue(CONFIG) + stop = second.stop + await second.resend() + await vi.waitFor(async () => expect(await storedCount()).toBe(0)) + expect(posts).toEqual(["https://ingest.test/v1/traces"]) + }) + + it("drops batches captured before consent was withdrawn instead of sending them", async () => { const posts: string[] = [] vi.stubGlobal( "fetch", @@ -145,9 +177,10 @@ describe("offline queue and consent", () => { await vi.waitFor(async () => expect(await storedCount()).toBe(1)) first.stop() - // A later page load that only has consent from now on. - configurePrivacy({ requireConsent: true }) + // Consent was withdrawn somewhere this queue never saw (another tab, which records it), then granted again. await new Promise((resolve) => setTimeout(resolve, 5)) + localStorage.setItem("maple-offline-revoked-at", String(Date.now())) + configurePrivacy({ requireConsent: true }) setConsent(true) vi.stubGlobal( "fetch", diff --git a/packages/browser/src/deferred/offline.ts b/packages/browser/src/deferred/offline.ts index 4bca3e99b..aac0aaf9d 100644 --- a/packages/browser/src/deferred/offline.ts +++ b/packages/browser/src/deferred/offline.ts @@ -2,13 +2,7 @@ // the exporters send, and sent again when the browser is back online or on the // next page load. Best-effort: a private window or blocked storage just means // nothing is kept. -import { - consentAllowedSince, - hasConsent, - ingestHeaders, - onConsentChange, - sdkHint, -} from "@maple/browser-session" +import { hasConsent, ingestHeaders, onConsentChange, sdkHint } from "@maple/browser-session" import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer" import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" @@ -49,11 +43,33 @@ const settle = (request: IDBRequest): Promise => function openDb(): Promise { if (typeof indexedDB === "undefined") return Promise.resolve(undefined) - const request = indexedDB.open(DB_NAME, 1) - request.onupgradeneeded = () => { - request.result.createObjectStore(STORE, { keyPath: "id", autoIncrement: true }) + try { + const request = indexedDB.open(DB_NAME, 1) + request.onupgradeneeded = () => { + request.result.createObjectStore(STORE, { keyPath: "id", autoIncrement: true }) + } + return settle(request).catch(() => undefined) + } catch { + // Opaque origins (sandboxed iframes, `data:` pages) throw synchronously. + return Promise.resolve(undefined) + } +} + +/** When consent was last withdrawn, in any tab of this origin. Persisted: the in-memory grant time resets every load. */ +const REVOKED_AT_KEY = "maple-offline-revoked-at" + +function readRevokedAt(): number { + try { + return Number(localStorage.getItem(REVOKED_AT_KEY)) || 0 + } catch { + return 0 } - return settle(request).catch(() => undefined) +} + +function writeRevokedAt(at: number): void { + try { + localStorage.setItem(REVOKED_AT_KEY, String(at)) + } catch {} } export interface OfflineQueue { @@ -87,9 +103,10 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { const drain = async (): Promise => { const read = await store("readonly") const stored = read ? (await settle(read.getAll())).filter(isStoredBatch) : [] + const revokedAt = readRevokedAt() for (const batch of stored) { - // Expired, or captured before the current consent grant (a revoke this queue never saw): drop it. - if (Date.now() - batch.createdAt <= MAX_AGE_MS && batch.createdAt >= consentAllowedSince()) { + // Expired, or captured before consent was last withdrawn (a revoke this queue never saw): drop it. + if (Date.now() - batch.createdAt <= MAX_AGE_MS && batch.createdAt > revokedAt) { const response = await fetch(`${config.endpoint}/v1/${batch.signal}`, { method: "POST", headers, @@ -136,23 +153,30 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { window.addEventListener("online", onOnline) // Withdrawn consent also withdraws what was kept for later. const stopConsent = onConsentChange((allowed) => { - if (!allowed) void clear().catch(() => {}) + if (allowed) return + writeRevokedAt(Date.now()) + void clear().catch(() => {}) }) void resend() + /** Writes in flight: `stop` closes the database only after them, so a stash made on the way out lands. */ + let writes: Promise = Promise.resolve() + const stash = (signal: Signal, body: Uint8Array | undefined): void => { + writes = writes.then(() => add(signal, body)).catch(() => {}) + } + return { stashSpans: (spans) => { - if (spans.length > 0) - void add("traces", JsonTraceSerializer.serializeRequest(spans)).catch(() => {}) + if (spans.length > 0) stash("traces", JsonTraceSerializer.serializeRequest(spans)) }, stashLogs: (logs) => { - if (logs.length > 0) void add("logs", JsonLogsSerializer.serializeRequest(logs)).catch(() => {}) + if (logs.length > 0) stash("logs", JsonLogsSerializer.serializeRequest(logs)) }, resend, stop: () => { window.removeEventListener("online", onOnline) stopConsent() - void db.then((opened) => opened?.close()) + void writes.then(() => db).then((opened) => opened?.close()) }, } } diff --git a/packages/browser/src/deferred/perf.ts b/packages/browser/src/deferred/perf.ts index e473ea657..78ea3f093 100644 --- a/packages/browser/src/deferred/perf.ts +++ b/packages/browser/src/deferred/perf.ts @@ -3,7 +3,7 @@ // presentation). Opt-in; nested under the open navigation when there is one. import { hasConsent, scrubUrl, selectorOf } from "@maple/browser-session" import { context, trace } from "@opentelemetry/api" -import { openNavigationSpan } from "../navigation" +import { navigationSpanAt } from "../navigation" import { liveMapleTracer } from "../tracing" import { SDK_NAME, SDK_VERSION } from "../version" @@ -34,7 +34,8 @@ function span( ): void { const tracer = hasConsent() ? liveMapleTracer(SDK_NAME, SDK_VERSION) : undefined if (!tracer) return - const navigation = openNavigationSpan() + // Buffered entries can predate the open navigation: those stay roots. + const navigation = navigationSpanAt(epoch(start)) const parent = navigation ? trace.setSpan(context.active(), navigation) : context.active() tracer.startSpan(name, { startTime: epoch(start), attributes }, parent).end(epoch(start + duration)) } @@ -144,7 +145,7 @@ export function startPerf(options: PerfOptions): () => void { // once, named after the event whose handlers ran longest. const byInteraction = new Map() for (const entry of entries) { - if (!(entry instanceof PerformanceEventTiming) || entry.interactionId === 0) continue + if (!(entry instanceof PerformanceEventTiming) || !entry.interactionId) continue if (entry.duration < SLOW_INTERACTION_MS || seen.has(entry.interactionId)) continue const best = byInteraction.get(entry.interactionId) if (!best || processingOf(entry) > processingOf(best)) diff --git a/packages/browser/src/deferred/reports.ts b/packages/browser/src/deferred/reports.ts index 5ccec5d8c..b3a57726c 100644 --- a/packages/browser/src/deferred/reports.ts +++ b/packages/browser/src/deferred/reports.ts @@ -98,6 +98,7 @@ export function startReports(options: ReportOptions): () => void { }) } + const stops: Array<() => void> = [] if (typeof ReportingObserver === "function") { const types = [ ...(options.csp ? ["csp-violation"] : []), @@ -115,19 +116,23 @@ export function startReports(options: ReportOptions): () => void { { types, buffered: true }, ) observer.observe() - return () => observer.disconnect() + stops.push(() => observer.disconnect()) + } + if (options.csp) { + // Also the DOM event: not every ReportingObserver delivers `csp-violation`. `once` dedupes the two paths. + 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) + stops.push(() => document.removeEventListener("securitypolicyviolation", onViolation)) + } + return () => { + for (const stop of stops) stop() } - 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) } diff --git a/packages/browser/src/error-causes.test.ts b/packages/browser/src/error-causes.test.ts index 0a8205538..91d971e8e 100644 --- a/packages/browser/src/error-causes.test.ts +++ b/packages/browser/src/error-causes.test.ts @@ -49,6 +49,17 @@ describe("stackWithCauses", () => { expect(stackWithCauses(deep)?.match(/Caused by/g)).toHaveLength(5) }) + it("never throws on a cause that cannot be turned into a string", () => { + const hostile = Object.create(null) + const throwing = { + toString() { + throw new Error("nope") + }, + } + expect(stackWithCauses(new Error("a", { cause: hostile }))).toContain("Caused by: [object Object]") + expect(stackWithCauses(new Error("b", { cause: throwing }))).toContain("Caused by: [object Object]") + }) + 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" }) diff --git a/packages/browser/src/error-causes.ts b/packages/browser/src/error-causes.ts index fff432e35..a4825ffe7 100644 --- a/packages/browser/src/error-causes.ts +++ b/packages/browser/src/error-causes.ts @@ -13,7 +13,9 @@ function linkedErrors(error: Error): unknown[] { while (queue.length > 0 && linked.length < MAX_LINKED) { const current = queue.shift() const next: unknown[] = [] - if (current instanceof AggregateError) next.push(...current.errors) + // Guarded: `AggregateError` is missing on older engines, and a bare reference would throw. + if (typeof AggregateError === "function" && 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 @@ -32,8 +34,17 @@ function framesOf(error: Error): string { return stack.startsWith(header) ? stack.slice(header.length).replace(/^\n/, "") : stack } +/** A null-prototype object or a throwing `toString` must not break the error path. */ +function render(value: unknown): string { + try { + return String(value) + } catch { + return Object.prototype.toString.call(value) + } +} + function describe(value: unknown): string { - if (!(value instanceof Error)) return `Caused by: ${String(value)}` + if (!(value instanceof Error)) return `Caused by: ${render(value)}` const frames = framesOf(value) const header = `Caused by: ${value.name}: ${value.message}` return frames ? `${header}\n${frames}` : header diff --git a/packages/browser/src/errors.browser.test.ts b/packages/browser/src/errors.browser.test.ts index 75d8113a7..26ccba111 100644 --- a/packages/browser/src/errors.browser.test.ts +++ b/packages/browser/src/errors.browser.test.ts @@ -4,13 +4,14 @@ import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor } from " import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" import { configureErrorFilters } from "./error-filters" import { captureException, resetReportedErrorsForTests, setupErrorCapture } from "./errors" +import { setMapleProviderForTests } from "./tracing" const exporter = new InMemorySpanExporter() const provider = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(exporter)], }) -trace.setGlobalTracerProvider(provider) +setMapleProviderForTests(provider) const exceptionEventOf = (span: ReadableSpan) => span.events.find((event) => event.name === "exception") @@ -58,14 +59,27 @@ describe("captureException", () => { it("still records an error reported before tracing was live", () => { resetReportedErrorsForTests() const error = new Error("early") - trace.disable() + setMapleProviderForTests(undefined) captureException(error) - trace.setGlobalTracerProvider(provider) + setMapleProviderForTests(provider) captureException(error) assert.strictEqual(exporter.getFinishedSpans().length, 1) }) + it("never records into a host app's global provider before init", () => { + const hostExporter = new InMemorySpanExporter() + trace.setGlobalTracerProvider( + new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(hostExporter)] }), + ) + setMapleProviderForTests(undefined) + captureException(new Error("before init")) + setMapleProviderForTests(provider) + trace.disable() + + assert.strictEqual(hostExporter.getFinishedSpans().length, 0) + }) + it("carries a custom name and caller attributes", () => { captureException(new Error("boom"), { name: "browser.uncaught_error", diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index 44d4c3b47..7d791733b 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -9,12 +9,12 @@ // Each error becomes a one-off span carrying an `exception` event and status // 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 { hasConsent, scrubUrl } from "@maple/browser-session" 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" -import { mapleTracer } from "./tracing" +import { liveMapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" export interface CaptureExceptionOptions { @@ -72,14 +72,15 @@ export function resetReportedErrorsForTests(): void { * takes the Error status, so one error stays one issue. The error is claimed * only when the span is recording: before `init()` the tracer is a no-op, and * claiming it then would swallow the same error reported again once tracing is - * live. + * live. Without consent the span is never exported, so the error is not claimed either. */ export function recordFailure(span: Span, error: unknown): void { const normalized = asError(error) if (!alreadyReported(error)) { - if (span.isRecording() && typeof error === "object" && error !== null) reported.add(error) + const exported = span.isRecording() && hasConsent() + if (exported && typeof error === "object" && error !== null) reported.add(error) span.recordException(exceptionOf(normalized)) - if (span.isRecording()) { + if (exported) { for (const listener of errorListeners) { // A listener must never turn one error into another. try { @@ -101,8 +102,11 @@ function recordException( source: ErrorSource, filename?: string, ): void { - if (!shouldCapture(asError(error), { source, originalError: error }, filename)) return - const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan( + if (!hasConsent() || !shouldCapture(asError(error), { source, originalError: error }, filename)) return + // Maple's own provider only: before `init()` the global one may be the host app's. + const tracer = liveMapleTracer(SDK_NAME, SDK_VERSION) + if (!tracer) return + const span = tracer.startSpan( options.name ?? "exception", { kind: SpanKind.INTERNAL, @@ -118,15 +122,19 @@ function recordException( } /** - * Record an error that no span was watching. Safe before `init()`: without a - * registered provider the OTel API hands back a no-op tracer and this does - * nothing. Reporting the same error object twice records it once. + * Record an error that no span was watching. Safe before `init()`, where it + * does nothing. Reporting the same error object twice records it once. */ export function captureException(error: unknown, options: CaptureExceptionOptions = {}): void { if (alreadyReported(error)) return recordException(error, options, "captureException") } +/** Whether the app's error filters keep `error`, for failures recorded on an existing span. */ +export function passesErrorFilters(error: unknown): boolean { + return shouldCapture(asError(error), { source: "captureException", originalError: error }) +} + /** * Register global handlers for uncaught errors and unhandled rejections. * Returns a teardown that removes them. diff --git a/packages/browser/src/http-headers.ts b/packages/browser/src/http-headers.ts index ee7653358..66efb5f97 100644 --- a/packages/browser/src/http-headers.ts +++ b/packages/browser/src/http-headers.ts @@ -3,7 +3,13 @@ import type { Span } from "@opentelemetry/api" /** Never recorded, even when listed: they carry credentials. */ -const CREDENTIAL_HEADERS = new Set(["authorization", "proxy-authorization", "cookie", "set-cookie"]) +const CREDENTIAL_HEADERS = new Set([ + "authorization", + "proxy-authorization", + "cookie", + "set-cookie", + "x-api-key", +]) export interface HeaderCapture { readonly request: ReadonlyArray @@ -18,6 +24,16 @@ export function resolveHeaderCapture( return { request: clean(raw?.request), response: clean(raw?.response) } } +/** `getAllResponseHeaders()` text as a lower-cased map: only the headers CORS exposes are in it. */ +export function responseHeaders(raw: string): Map { + const headers = new Map() + for (const line of raw.split(/\r?\n/)) { + const colon = line.indexOf(":") + if (colon > 0) headers.set(line.slice(0, colon).trim().toLowerCase(), line.slice(colon + 1).trim()) + } + return headers +} + /** Stamp the listed headers that `read` finds. Cross-origin responses expose only CORS-safelisted headers unless the server lists more in `Access-Control-Expose-Headers`. */ export function setHeaderAttributes( span: Span, diff --git a/packages/browser/src/http-status.test.ts b/packages/browser/src/http-status.test.ts index ef26e9816..fb40da522 100644 --- a/packages/browser/src/http-status.test.ts +++ b/packages/browser/src/http-status.test.ts @@ -105,4 +105,16 @@ describe("errors.captureHttpStatus", () => { ) expect(notFound.status.code).toBe(SpanStatusCode.UNSET) }) + + it("keeps a network failure an error and describes it like a status error", () => { + const failed = finish((s) => { + s.setAttribute("http.request.method", "POST") + s.setAttribute("url.full", "https://api.test/orders?id=7") + s.setAttribute("http.response.status_code", 0) + s.setAttribute("error.type", "TypeError") + s.setStatus({ code: SpanStatusCode.ERROR, message: "Failed to fetch" }) + }) + expect(failed.status.code).toBe(SpanStatusCode.ERROR) + expect(failed.attributes["error.message"]).toBe("POST https://api.test/orders -> TypeError") + }) }) diff --git a/packages/browser/src/http-status.ts b/packages/browser/src/http-status.ts index 01a1aec07..760da03d1 100644 --- a/packages/browser/src/http-status.ts +++ b/packages/browser/src/http-status.ts @@ -22,7 +22,7 @@ const inRanges = (status: number, ranges: ReadonlyArray): boole ) /** `GET https://api.example.com/users/42 -> 500`, without the query: ids are redacted by the issue fingerprint. */ -function failureMessage(span: ReadableSpan, status: number): string { +function failureMessage(span: ReadableSpan, status: number | string): string { const method = span.attributes["http.request.method"] ?? span.attributes["http.method"] ?? "GET" const url = String(span.attributes["url.full"] ?? span.attributes["http.url"] ?? "").replace( /[?#].*$/, @@ -83,6 +83,21 @@ export class HttpStatusExporter implements SpanExporter { }, ) } + const errorType = span.attributes["error.type"] + if ( + span.kind === SpanKind.CLIENT && + span.status.code === SpanStatusCode.ERROR && + typeof errorType === "string" && + !HTTP_STATUS.test(errorType) && + span.attributes["error.message"] === undefined && + !span.events.some((event) => event.name === "exception") + ) { + // A network failure (`TypeError`, `error`, `timeout`): the same message shape as a status error. + return withStatus(span, span.status, { + ...span.attributes, + "error.message": failureMessage(span, errorType), + }) + } if (!isStatusOnlyError(span)) return span const { "error.type": _errorType, ...attributes } = span.attributes return withStatus(span, { code: SpanStatusCode.UNSET }, attributes) diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index fb9ee397f..233819e6f 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -252,10 +252,11 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { // Before the provider shuts down, so an open navigation exports with it resetNavigation() await deferredPending - await stopDeferred?.() - stopDeferred = undefined + // Tracing first: its last flush may fail into the offline queue, which the deferred stop closes. await shutdownTracing?.() shutdownTracing = undefined + await stopDeferred?.() + stopDeferred = undefined setActiveTraceIdProvider(() => undefined) active = undefined activeConfig = undefined diff --git a/packages/browser/src/navigation.ts b/packages/browser/src/navigation.ts index 7ed3dafcf..a42cb57bf 100644 --- a/packages/browser/src/navigation.ts +++ b/packages/browser/src/navigation.ts @@ -20,7 +20,7 @@ import { trace, type Tracer, } from "@opentelemetry/api" -import { captureException, recordFailure } from "./errors" +import { captureException, passesErrorFilters, recordFailure } from "./errors" import { liveMapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -34,11 +34,14 @@ export interface TracedOptions { * The navigation in flight. Only ever set in a browser: a server shares module * state across requests. */ -let navigation: { readonly span: Span; readonly kind: "pageload" | "navigate" } | undefined +let navigation: + | { readonly span: Span; readonly kind: "pageload" | "navigate"; readonly startedAt: number } + | undefined /** * Whether the next navigation is a `pageload`: the first since the document - * loaded, or since `shutdown()`. Consumed even when nothing is spanned - * (tracing not live yet, consent pending): a later click is not the page load. + * loaded, or since `shutdown()`. Consumed even when nothing is spanned (tracing + * not live yet, consent pending): a later click is not the page load. Router + * adapters must therefore be attached after `init()`. */ let firstLoad = true /** @@ -75,6 +78,11 @@ export function openNavigationSpan(): Span | undefined { return navigation?.span } +/** The open navigation span, if it had already started at `epochMs`: a child must not begin before its parent. */ +export function navigationSpanAt(epochMs: number): Span | undefined { + return navigation && navigation.startedAt <= epochMs ? navigation.span : undefined +} + /** End the open navigation as interrupted: something other than its route finishing ended it. */ function interruptNavigation(): void { navigation?.span.setAttribute("app.navigation.interrupted", true) @@ -96,7 +104,7 @@ export function startNavigation(path: string): void { // but never before a consent grant: the exporter drops anything that began earlier. const startTime = joinServer ? Math.max(performance.timeOrigin, consentAllowedSince()) : undefined const span = live.startSpan(kind, { startTime, attributes: { "url.path": scrubUrl(path) } }, parent) - navigation = { kind, span } + navigation = { kind, span, startedAt: startTime ?? Date.now() } if (joinServer) { if (pageloadListener) pageloadListener(live, span) else pendingPageload = { tracer: live, span } @@ -126,7 +134,8 @@ export async function traced(name: string, fn: () => Promise, options: Tra try { return await fn() } catch (error) { - if (isFailure(options, error)) { + // The same filters as the global handlers: a dropped error is no failure here either. + if (isFailure(options, error) && passesErrorFilters(error)) { // An unsampled span drops what it records: report the failure on its own, which is always kept. if (span.isRecording()) recordFailure(span, error) else captureException(error, { name }) diff --git a/packages/browser/src/react.browser.test.ts b/packages/browser/src/react.browser.test.ts index e280de15c..5af2a0e5e 100644 --- a/packages/browser/src/react.browser.test.ts +++ b/packages/browser/src/react.browser.test.ts @@ -170,6 +170,21 @@ describe("instrumentReactRouter", () => { await stop() expect(spanNames()).toEqual(["pageload /", "navigate /projects/:id"]) }) + + it("does not span a search-only change or a revalidation of the same path", async () => { + const router = fakeReactRouter({ + initialized: true, + location: { pathname: "/" }, + navigation: idle, + matches: [{ route: { path: "/" } }], + }) + const unsubscribe = instrumentReactRouter(router) + router.set({ navigation: { state: "loading", location: { pathname: "/" } } }) + router.set({ navigation: idle }) + unsubscribe() + await stop() + expect(spanNames()).toEqual(["pageload /"]) + }) }) type TanStackEvent = { toLocation: { pathname: string }; pathChanged: boolean } diff --git a/packages/browser/src/react.ts b/packages/browser/src/react.ts index dc0ccb413..5c0924ee0 100644 --- a/packages/browser/src/react.ts +++ b/packages/browser/src/react.ts @@ -126,7 +126,8 @@ export function instrumentReactRouter(router: ReactRouterLike): () => void { return router.subscribe((state) => { const target = state.navigation.location?.pathname if (state.navigation.state !== "idle" && target !== undefined) { - if (!open || target !== pathname) start(target) + // Same path means a search-only change or a revalidation: not a navigation. + if (target !== pathname) start(target) pathname = target return } diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index f18b84c98..1cefa3b0c 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -262,6 +262,23 @@ describe("setupTracing unload flush", () => { URL.revokeObjectURL(url) }) + it("marks a fetch that never got a response as an error", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }) + const poll = { interval: 0 } + shutdown = setupTracing({ ...CONFIG, tracingInstrumentFetch: true }) + const url = URL.createObjectURL(new Blob(["gone"])) + URL.revokeObjectURL(url) + + await expect(fetch(url)).rejects.toThrow(TypeError) + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1), poll) + window.dispatchEvent(new Event("pagehide")) + await vi.waitFor(() => expect(exported).toHaveLength(1), poll) + + expect(exported[0]?.status.code).toBe(2) + expect(exported[0]?.attributes["error.type"]).toBe("TypeError") + expect(exported[0]?.attributes["error.message"]).toBe(`GET ${url} -> TypeError`) + }) + it("does not end a fetch span the instrumentation already ended", async () => { const errors: string[] = [] const noop = (): void => {} diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts index 7c9e0e172..84147cbec 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -12,7 +12,9 @@ import { propagation, ProxyTracerProvider, type Span as ApiSpan, + SpanStatusCode, type Tracer, + type TracerProvider, trace, } from "@opentelemetry/api" import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http" @@ -25,7 +27,7 @@ import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base" import { WebTracerProvider } from "@opentelemetry/sdk-trace-web" import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions" import type { ResolvedConfig } from "./config" -import { setHeaderAttributes } from "./http-headers" +import { responseHeaders, setHeaderAttributes } from "./http-headers" import { HttpStatusExporter } from "./http-status" import { OfflineSpanExporter } from "./offline" import { SessionSampler } from "./sampling" @@ -109,22 +111,22 @@ class ConsentSpanExporter implements SpanExporter { const EXPORT_INTERVAL_MS = 2_000 /** - * The provider this SDK registered, while it is live. `captureException` spans - * through it directly: the global provider may belong to the host app, which - * registered first and so kept the global. + * The provider this SDK registered, while it is live. Everything Maple spans + * goes through it directly: the global provider may belong to the host app, + * which registered first and so kept the global. */ -let mapleProvider: WebTracerProvider | undefined - -/** A tracer on Maple's provider when tracing is live, otherwise the global one. */ -export function mapleTracer(name: string, version: string): Tracer { - return (mapleProvider ?? trace.getTracerProvider()).getTracer(name, version) -} +let mapleProvider: TracerProvider | undefined /** A tracer on Maple's provider while tracing is live, with no global fallback. */ export function liveMapleTracer(name: string, version: string): Tracer | undefined { return mapleProvider?.getTracer(name, version) } +/** Test seam: stand in for the provider `init()` registers. */ +export function setMapleProviderForTests(provider: TracerProvider | undefined): void { + mapleProvider = provider +} + /** Resource attributes shared by every signal this SDK exports. */ export function resourceAttributes(config: ResolvedConfig): Record { const attributes: Record = { @@ -273,6 +275,10 @@ export function setupTracing(config: ResolvedConfig): () => Promise { setHeaderAttributes(span, "response", headers.response, (name) => result.headers.get(name), ) + } else if (result instanceof Error && result.name !== "AbortError") { + // A rejected fetch (offline, DNS, CORS) ends with status 0 and no error; XHR marks its own. + span.setStatus({ code: SpanStatusCode.ERROR, message: result.message }) + span.setAttribute("error.type", result.name) } }, }), @@ -284,8 +290,11 @@ export function setupTracing(config: ResolvedConfig): () => Promise { ...requestOptions, applyCustomAttributesOnSpan: (span, xhr) => { noteSettled(span) + if (headers.response.length === 0) return + // One read of the exposed headers: asking for an unexposed one by name logs a console error. + const exposed = responseHeaders(xhr.getAllResponseHeaders()) setHeaderAttributes(span, "response", headers.response, (name) => - xhr.getResponseHeader(name), + exposed.get(name), ) }, }), From c1c7ef65f76fe0a12dd00e25e2a5a47b01e6dbf5 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 17:04:48 +0200 Subject: [PATCH 2/4] fix(browser): second review pass on the review fixes - Consent: the revoke is now recorded by the consent module on an explicit setConsent(false) after a grant, so it holds even where the offline queue never ran (revoked before the deferred chunk loaded, or after shutdown). A page starting without consent is not a revoke. - A fetch aborted with a custom reason (controller.abort(reason)) is no longer marked as a network failure: the request's signal decides. - traced() runs beforeCapture once for a failure in an unsampled session. - Input breadcrumbs merge only when both have a target selector. - Buffered replay still checks out while hidden when the buffer is empty or the current segment nears the size cap, and a takeFullSnapshot throw can no longer escape the timer. - Docs: request bodies need maskAllInputs off (options table), only string request bodies are kept, and the README says to attach router adapters after init(). --- docs/browser-sdk.md | 7 ++--- .../src/identity/consent.test.ts | 18 +++++++++++++ .../browser-session/src/identity/consent.ts | 23 +++++++++++++++- packages/browser-session/src/index.ts | 1 + .../browser-session/src/replay/record.test.ts | 21 +++++++++++++++ packages/browser-session/src/replay/record.ts | 11 ++++++-- packages/browser/README.md | 2 ++ packages/browser/src/deferred/breadcrumbs.ts | 7 ++++- .../src/deferred/offline.browser.test.ts | 4 +-- packages/browser/src/deferred/offline.ts | 26 +++---------------- packages/browser/src/errors.ts | 10 ++++++- .../browser/src/navigation.browser.test.ts | 20 ++++++++++++++ packages/browser/src/navigation.ts | 4 +-- packages/browser/src/tracing.browser.test.ts | 18 +++++++++++++ packages/browser/src/tracing.ts | 7 ++++- 15 files changed, 144 insertions(+), 35 deletions(-) diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index c46c367e9..49d598b17 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -67,7 +67,7 @@ Every field accepted by `MapleBrowser.init`: | `tracing.slowInteractions` | `boolean` | `false` | Span interactions of 200ms or more. See [Jank](#jank). | | `tracing.captureHeaders` | `{ request?, response? }` | none | Header names recorded on `fetch`/XHR spans as `http.request.header.` / `http.response.header.`. See [Request and response detail](#request-and-response-detail). | | `replay.canvasFps` | `number` | off | Record `` content at this many frames per second. | -| `replay.networkBodies` | `{ urls, maxLength? }` | none | Keep text request/response bodies of these URLs on replay network events. | +| `replay.networkBodies` | `{ urls, maxLength? }` | none | Keep text response bodies of these URLs on replay network events, and request bodies too with `privacy.maskAllInputs: false`. | | `replay.onErrorSampleRate` | `number` | `0` | Fraction of the sessions not recorded that buffer the last minute in memory and keep it only if an error happens. See [Sampling](#sampling). | | `transport.offline` | `boolean` | `false` | Keep span and log batches that could not be sent in IndexedDB for up to 24 hours and send them later. See [Offline](#offline). | | `privacy.maskAllInputs` | `boolean` | `true` | Mask all `` values in the recording. | @@ -555,8 +555,9 @@ Patterns match the full URL, so a relative `fetch("/api/checkout")` is matched a `https://your.app/api/checkout`. Only text and JSON bodies are kept, each cut to `maxLength` characters (at most and by default 1,000: ingest stores up to 1 KB per body). The response is read from a clone in the background, only as far as `maxLength` and for at most 5 seconds, so your code gets it untouched and unwaited; event streams (`text/event-stream`) are never read. Nothing is captured with `privacy.maskAllText`, and -request bodies only with `privacy.maskAllInputs: false`, since a form POST carries what was typed. Bodies -can hold personal data: list only endpoints whose payloads you are allowed to record. +request bodies only with `privacy.maskAllInputs: false`, since a form POST carries what was typed. Only +string request bodies are kept (not `FormData`, `Blob` or a stream). Bodies can hold personal data: list +only endpoints whose payloads you are allowed to record. ### Canvas diff --git a/packages/browser-session/src/identity/consent.test.ts b/packages/browser-session/src/identity/consent.test.ts index 691da117b..35a821bed 100644 --- a/packages/browser-session/src/identity/consent.test.ts +++ b/packages/browser-session/src/identity/consent.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest" import { configurePrivacy, consentAllowedSince, + consentRevokedAt, hasConsent, mayPersistIdentifier, onConsentChange, @@ -38,6 +39,23 @@ describe("consent", () => { expect(hasConsent()).toBe(false) }) + it("persists when consent was withdrawn, so data kept from before can be dropped on a later page", () => { + const store = new Map() + vi.stubGlobal("localStorage", { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => store.set(key, value), + }) + // A page that starts without consent has not had it revoked. + configurePrivacy({ requireConsent: true }) + setConsent(false) + expect(consentRevokedAt()).toBe(0) + setConsent(true) + expect(consentRevokedAt()).toBe(0) + vi.useFakeTimers({ now: 5_000 }) + setConsent(false) + expect(consentRevokedAt()).toBe(5_000) + }) + it("notifies only effective transitions and advances the grant boundary", () => { vi.useFakeTimers() vi.setSystemTime(new Date("2026-08-02T10:00:00Z")) diff --git a/packages/browser-session/src/identity/consent.ts b/packages/browser-session/src/identity/consent.ts index f04f3b7ed..5a675fe2d 100644 --- a/packages/browser-session/src/identity/consent.ts +++ b/packages/browser-session/src/identity/consent.ts @@ -84,6 +84,24 @@ function consentState(): ConsentState { return fresh } +/** When consent was last withdrawn, on any page of this origin. Persisted: the grant time resets every load. */ +const REVOKED_AT_KEY = "maple-consent-revoked-at" + +function writeRevokedAt(at: number): void { + try { + localStorage.setItem(REVOKED_AT_KEY, String(at)) + } catch {} +} + +/** Epoch ms of the last consent withdrawal on this origin, 0 if never. Data kept from before it must not be sent. */ +export function consentRevokedAt(): number { + try { + return Number(localStorage.getItem(REVOKED_AT_KEY)) || 0 + } catch { + return 0 + } +} + function updateEffectiveConsent(previous: boolean): void { const state = consentState() const allowed = hasConsent() @@ -125,7 +143,10 @@ export function configurePrivacy(options: PrivacyOptions | undefined): void { /** Record the user's consent decision. No-op unless `requireConsent` is set. */ export function setConsent(nextGranted: boolean): void { const previous = hasConsent() - consentState().granted = nextGranted + const state = consentState() + // Only the user taking consent back is a revoke; a page starting without it is not. + if (state.granted && !nextGranted) writeRevokedAt(Date.now()) + state.granted = nextGranted updateEffectiveConsent(previous) } diff --git a/packages/browser-session/src/index.ts b/packages/browser-session/src/index.ts index f94fbcc4b..462c4ad5b 100644 --- a/packages/browser-session/src/index.ts +++ b/packages/browser-session/src/index.ts @@ -2,6 +2,7 @@ export type { PrivacyOptions } from "./identity/consent" export { configurePrivacy, consentAllowedSince, + consentRevokedAt, hasConsent, mayPersistIdentifier, onConsentChange, diff --git a/packages/browser-session/src/replay/record.test.ts b/packages/browser-session/src/replay/record.test.ts index fa561f04b..4de3e372d 100644 --- a/packages/browser-session/src/replay/record.test.ts +++ b/packages/browser-session/src/replay/record.test.ts @@ -277,6 +277,27 @@ describe("startBufferedRecording", () => { recorder.stop() vi.useRealTimers() }) + + it("waits for the page to be shown, unless the buffer has nothing to play back", () => { + vi.useFakeTimers() + vi.stubGlobal("document", { visibilityState: "hidden" }) + takeFullSnapshot.mockClear() + const recorder = startBufferedRecording(CONFIG, "session-1") + snapshot(1_000) + emitRef!(incremental(1_500)) + vi.advanceTimersByTime(30_000) + expect(takeFullSnapshot).not.toHaveBeenCalled() + recorder.stop() + + // Nothing buffered yet (or the size cap emptied it): a snapshot is due even while hidden. + const empty = startBufferedRecording(CONFIG, "session-1") + emitRef!(incremental(2_000)) + vi.advanceTimersByTime(30_000) + expect(takeFullSnapshot).toHaveBeenCalledWith(true) + empty.stop() + vi.unstubAllGlobals() + vi.useRealTimers() + }) }) describe("canvas capture", () => { diff --git a/packages/browser-session/src/replay/record.ts b/packages/browser-session/src/replay/record.ts index 54614771f..9d4a5c7f6 100644 --- a/packages/browser-session/src/replay/record.ts +++ b/packages/browser-session/src/replay/record.ts @@ -351,8 +351,15 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): const checkoutTimer = setInterval(() => { if (stopped || changedSinceSnapshot === 0) return - if (typeof document !== "undefined" && document.visibilityState === "hidden") return - record.takeFullSnapshot(true) + const current = segments.at(-1) + // Hidden, a checkout waits, unless the size cap emptied the buffer or is about to. + const urgent = current === undefined || current.bytes > MAX_BUFFER_BYTES / 2 + if (!urgent && typeof document !== "undefined" && document.visibilityState === "hidden") return + try { + record.takeFullSnapshot(true) + } catch { + // rrweb throws when it is not recording; capture must never throw into the page. + } }, BUFFER_CHECKOUT_MS) return { diff --git a/packages/browser/README.md b/packages/browser/README.md index 1343968b8..cbc0e663c 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -218,6 +218,8 @@ instrumentReactRouter(createBrowserRouter(routes)) // navigate /projects/:id instrumentTanStackRouter(router) // navigate /projects/$projectId ``` +Attach them after `MapleBrowser.init`: a navigation reported earlier uses up the page load. + ## Linking a marketing site to your app The visitor id lives in localStorage **and** a cookie scoped to your registered diff --git a/packages/browser/src/deferred/breadcrumbs.ts b/packages/browser/src/deferred/breadcrumbs.ts index d47517287..3bdaea555 100644 --- a/packages/browser/src/deferred/breadcrumbs.ts +++ b/packages/browser/src/deferred/breadcrumbs.ts @@ -33,7 +33,12 @@ let active = false function push(crumb: Crumb): void { // Typing is one `input` per keystroke: keep one crumb per field, or a message evicts the whole trail. const last = crumbs.at(-1) - if (crumb.type === "input" && last?.type === "input" && last.target === crumb.target) { + if ( + crumb.type === "input" && + crumb.target !== undefined && + last?.type === "input" && + last.target === crumb.target + ) { crumbs[crumbs.length - 1] = crumb return } diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/browser/src/deferred/offline.browser.test.ts index a6416df62..51692e972 100644 --- a/packages/browser/src/deferred/offline.browser.test.ts +++ b/packages/browser/src/deferred/offline.browser.test.ts @@ -41,7 +41,7 @@ const storedCount = async (): Promise => { let stop: (() => void) | undefined beforeEach(async () => { - localStorage.removeItem("maple-offline-revoked-at") + localStorage.removeItem("maple-consent-revoked-at") await new Promise((resolve) => { const request = indexedDB.deleteDatabase("maple-offline") request.onsuccess = request.onerror = request.onblocked = () => resolve(undefined) @@ -179,7 +179,7 @@ describe("offline queue and consent", () => { // Consent was withdrawn somewhere this queue never saw (another tab, which records it), then granted again. await new Promise((resolve) => setTimeout(resolve, 5)) - localStorage.setItem("maple-offline-revoked-at", String(Date.now())) + localStorage.setItem("maple-consent-revoked-at", String(Date.now())) configurePrivacy({ requireConsent: true }) setConsent(true) vi.stubGlobal( diff --git a/packages/browser/src/deferred/offline.ts b/packages/browser/src/deferred/offline.ts index aac0aaf9d..dc1756ab9 100644 --- a/packages/browser/src/deferred/offline.ts +++ b/packages/browser/src/deferred/offline.ts @@ -2,7 +2,7 @@ // the exporters send, and sent again when the browser is back online or on the // next page load. Best-effort: a private window or blocked storage just means // nothing is kept. -import { hasConsent, ingestHeaders, onConsentChange, sdkHint } from "@maple/browser-session" +import { consentRevokedAt, hasConsent, ingestHeaders, onConsentChange, sdkHint } from "@maple/browser-session" import { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer" import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" @@ -55,23 +55,6 @@ function openDb(): Promise { } } -/** When consent was last withdrawn, in any tab of this origin. Persisted: the in-memory grant time resets every load. */ -const REVOKED_AT_KEY = "maple-offline-revoked-at" - -function readRevokedAt(): number { - try { - return Number(localStorage.getItem(REVOKED_AT_KEY)) || 0 - } catch { - return 0 - } -} - -function writeRevokedAt(at: number): void { - try { - localStorage.setItem(REVOKED_AT_KEY, String(at)) - } catch {} -} - export interface OfflineQueue { readonly stashSpans: (spans: ReadableSpan[]) => void readonly stashLogs: (logs: ReadableLogRecord[]) => void @@ -103,7 +86,7 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { const drain = async (): Promise => { const read = await store("readonly") const stored = read ? (await settle(read.getAll())).filter(isStoredBatch) : [] - const revokedAt = readRevokedAt() + const revokedAt = consentRevokedAt() for (const batch of stored) { // Expired, or captured before consent was last withdrawn (a revoke this queue never saw): drop it. if (Date.now() - batch.createdAt <= MAX_AGE_MS && batch.createdAt > revokedAt) { @@ -153,9 +136,8 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { window.addEventListener("online", onOnline) // Withdrawn consent also withdraws what was kept for later. const stopConsent = onConsentChange((allowed) => { - if (allowed) return - writeRevokedAt(Date.now()) - void clear().catch(() => {}) + // The consent module records the revoke itself, so it holds even where this queue never ran. + if (!allowed) void clear().catch(() => {}) }) void resend() diff --git a/packages/browser/src/errors.ts b/packages/browser/src/errors.ts index 7d791733b..699d99efc 100644 --- a/packages/browser/src/errors.ts +++ b/packages/browser/src/errors.ts @@ -101,8 +101,10 @@ function recordException( options: CaptureExceptionOptions, source: ErrorSource, filename?: string, + filtered = false, ): void { - if (!hasConsent() || !shouldCapture(asError(error), { source, originalError: error }, filename)) return + if (!hasConsent()) return + if (!filtered && !shouldCapture(asError(error), { source, originalError: error }, filename)) return // Maple's own provider only: before `init()` the global one may be the host app's. const tracer = liveMapleTracer(SDK_NAME, SDK_VERSION) if (!tracer) return @@ -135,6 +137,12 @@ export function passesErrorFilters(error: unknown): boolean { return shouldCapture(asError(error), { source: "captureException", originalError: error }) } +/** `captureException` for an error `passesErrorFilters` already let through: `beforeCapture` runs once. */ +export function captureFilteredException(error: unknown, options: CaptureExceptionOptions = {}): void { + if (alreadyReported(error)) return + recordException(error, options, "captureException", undefined, true) +} + /** * Register global handlers for uncaught errors and unhandled rejections. * Returns a teardown that removes them. diff --git a/packages/browser/src/navigation.browser.test.ts b/packages/browser/src/navigation.browser.test.ts index 2b0faed48..175f012b1 100644 --- a/packages/browser/src/navigation.browser.test.ts +++ b/packages/browser/src/navigation.browser.test.ts @@ -372,6 +372,26 @@ describe("traced", () => { expect(parentOf(named("outer"))).toBeUndefined() }) + it("runs beforeCapture once for a loader failure in an unsampled session", async () => { + let calls = 0 + start({ + tracing: { sampleRate: 0, instrumentFetch: false, instrumentXhr: false }, + errors: { + beforeCapture: () => { + calls++ + return true + }, + }, + }) + await expect( + MapleBrowser.traced("loader", async () => { + throw new Error("load failed") + }), + ).rejects.toThrow("load failed") + await stop() + expect(calls).toBe(1) + }) + it("parents fetches started before the first await, but not after it", async () => { start() MapleBrowser.startNavigation("/a") diff --git a/packages/browser/src/navigation.ts b/packages/browser/src/navigation.ts index a42cb57bf..f572f83ea 100644 --- a/packages/browser/src/navigation.ts +++ b/packages/browser/src/navigation.ts @@ -20,7 +20,7 @@ import { trace, type Tracer, } from "@opentelemetry/api" -import { captureException, passesErrorFilters, recordFailure } from "./errors" +import { captureFilteredException, passesErrorFilters, recordFailure } from "./errors" import { liveMapleTracer } from "./tracing" import { SDK_NAME, SDK_VERSION } from "./version" @@ -138,7 +138,7 @@ export async function traced(name: string, fn: () => Promise, options: Tra if (isFailure(options, error) && passesErrorFilters(error)) { // An unsampled span drops what it records: report the failure on its own, which is always kept. if (span.isRecording()) recordFailure(span, error) - else captureException(error, { name }) + else captureFilteredException(error, { name }) } throw error } finally { diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index 1cefa3b0c..72bdeee6f 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -279,6 +279,24 @@ describe("setupTracing unload flush", () => { expect(exported[0]?.attributes["error.message"]).toBe(`GET ${url} -> TypeError`) }) + it("does not count a fetch aborted with a custom reason as a network failure", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }) + const poll = { interval: 0 } + shutdown = setupTracing({ ...CONFIG, tracingInstrumentFetch: true }) + const url = URL.createObjectURL(new Blob(["ok"])) + const controller = new AbortController() + controller.abort(new Error("unmounted")) + + await expect(fetch(url, { signal: controller.signal })).rejects.toThrow("unmounted") + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1), poll) + window.dispatchEvent(new Event("pagehide")) + await vi.waitFor(() => expect(exported).toHaveLength(1), poll) + + expect(exported[0]?.status.code).toBe(0) + expect(exported[0]?.attributes["error.type"]).toBeUndefined() + URL.revokeObjectURL(url) + }) + it("does not end a fetch span the instrumentation already ended", async () => { const errors: string[] = [] const noop = (): void => {} diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts index 84147cbec..bb34e7fe4 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -275,8 +275,13 @@ export function setupTracing(config: ResolvedConfig): () => Promise { setHeaderAttributes(span, "response", headers.response, (name) => result.headers.get(name), ) - } else if (result instanceof Error && result.name !== "AbortError") { + } else if ( + result instanceof Error && + result.name !== "AbortError" && + !request.signal?.aborted + ) { // A rejected fetch (offline, DNS, CORS) ends with status 0 and no error; XHR marks its own. + // An abort rejects with its reason, whatever that is, so the signal decides. span.setStatus({ code: SpanStatusCode.ERROR, message: result.message }) span.setAttribute("error.type", result.name) } From 1770f01a1ef9624ed7ba299fe56415ed5fb8950f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 17:15:53 +0200 Subject: [PATCH 3/4] test(browser-session): find network events by request, not arrival order Bodies are read in the background, so the order events land in is not the order the requests were made; the test picked them by index and flaked. --- .../src/replay/capture/network.browser.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/browser-session/src/replay/capture/network.browser.test.ts b/packages/browser-session/src/replay/capture/network.browser.test.ts index 9341b8cab..00af66aa5 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -74,7 +74,10 @@ describe("installNetworkCapture", () => { await fetch("https://api.test/other") await vi.waitFor(() => expect(events.filter((event) => event.type === "network")).toHaveLength(3)) - const [, listed, other] = events.filter((event) => event.type === "network") + // Bodies are read in the background, so events can land in any order: find them by request. + const network = events.filter((event) => event.type === "network") + const listed = network.find((event) => event.net?.method === "POST") + const other = network.find((event) => event.net?.url.endsWith("/other")) expect(listed?.attrs).toEqual({ "request.body": "request …", "response.body": '{"order"…' }) expect(other?.attrs).toBeUndefined() } finally { From a3f67fef090063ddf1a3048bb158527b713c28bf Mon Sep 17 00:00:00 2001 From: Makisuo Date: Wed, 30 Sep 2026 18:14:29 +0200 Subject: [PATCH 4/4] fix(browser): review comments on slow interactions and the offline queue - Slow interactions: only interactionId 0 means "not an interaction". An engine without interactionId no longer loses every slow-interaction span; its events are keyed by name and start time instead. - Offline queue: a batch is stamped when it is stashed and skipped if consent was withdrawn before its write ran, and a revoke's clear is queued behind pending writes, so nothing lands after the clear. - Offline resend re-checks consent before every POST, so a revoke during a multi-batch resend stops the rest. --- .../src/deferred/offline.browser.test.ts | 25 +++++++++++++++ packages/browser/src/deferred/offline.ts | 31 ++++++++++++------- .../browser/src/deferred/perf.browser.test.ts | 12 ++++++- packages/browser/src/deferred/perf.ts | 29 ++++++++++++----- 4 files changed, 77 insertions(+), 20 deletions(-) diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/browser/src/deferred/offline.browser.test.ts index 51692e972..0272b3f59 100644 --- a/packages/browser/src/deferred/offline.browser.test.ts +++ b/packages/browser/src/deferred/offline.browser.test.ts @@ -195,6 +195,31 @@ describe("offline queue and consent", () => { await vi.waitFor(async () => expect(await storedCount()).toBe(0)) expect(posts.filter((url) => url.endsWith("/v1/traces"))).toEqual([]) }) + + it("stops a resend when consent is withdrawn during it", async () => { + configurePrivacy({ requireConsent: true }) + setConsent(true) + let online = false + const posts: string[] = [] + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (!online) throw new TypeError("Failed to fetch") + posts.push(url) + // The user withdraws consent while the first batch is in flight. + setConsent(false) + return new Response("{}") + }), + ) + const queue = startOfflineQueue(CONFIG) + stop = queue.stop + queue.stashSpans(finishedSpans("one")) + queue.stashSpans(finishedSpans("two")) + await vi.waitFor(async () => expect(await storedCount()).toBe(2)) + online = true + await queue.resend() + expect(posts).toHaveLength(1) + }) }) describe("OfflineSpanExporter", () => { diff --git a/packages/browser/src/deferred/offline.ts b/packages/browser/src/deferred/offline.ts index dc1756ab9..888f21ac5 100644 --- a/packages/browser/src/deferred/offline.ts +++ b/packages/browser/src/deferred/offline.ts @@ -72,11 +72,12 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { const store = async (mode: IDBTransactionMode): Promise => (await db)?.transaction(STORE, mode).objectStore(STORE) - const add = async (signal: Signal, body: Uint8Array | undefined): Promise => { - if (!body || !hasConsent()) return + const add = async (signal: Signal, body: Uint8Array, createdAt: number): Promise => { + // Withdrawn while this write waited its turn: it must not land after the clear. + if (!hasConsent() || createdAt <= consentRevokedAt()) return const batches = await store("readwrite") if (!batches) return - await settle(batches.add({ signal, body, createdAt: Date.now() } satisfies StoredBatch)) + await settle(batches.add({ signal, body, createdAt } satisfies StoredBatch)) const keys = await settle(batches.getAllKeys()) for (const key of keys.slice(0, Math.max(0, keys.length - MAX_BATCHES))) await settle(batches.delete(key)) @@ -86,10 +87,11 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { const drain = async (): Promise => { const read = await store("readonly") const stored = read ? (await settle(read.getAll())).filter(isStoredBatch) : [] - const revokedAt = consentRevokedAt() for (const batch of stored) { + // Checked per batch: consent can be withdrawn while an earlier POST is in flight. + if (!hasConsent()) return // Expired, or captured before consent was last withdrawn (a revoke this queue never saw): drop it. - if (Date.now() - batch.createdAt <= MAX_AGE_MS && batch.createdAt > revokedAt) { + if (Date.now() - batch.createdAt <= MAX_AGE_MS && batch.createdAt > consentRevokedAt()) { const response = await fetch(`${config.endpoint}/v1/${batch.signal}`, { method: "POST", headers, @@ -132,21 +134,26 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { if (batches) await settle(batches.clear()) } + /** Writes in order: a revoke's clear runs after the writes before it, and `stop` closes the database after all of them. */ + let writes: Promise = Promise.resolve() + const queue = (write: () => Promise): void => { + writes = writes.then(write).catch(() => {}) + } + const stash = (signal: Signal, body: Uint8Array | undefined): void => { + // Stamped now, not when the write runs, so a revoke in between is seen for what it is. + const createdAt = Date.now() + if (body && hasConsent()) queue(() => add(signal, body, createdAt)) + } + const onOnline = (): void => void resend() window.addEventListener("online", onOnline) // Withdrawn consent also withdraws what was kept for later. const stopConsent = onConsentChange((allowed) => { // The consent module records the revoke itself, so it holds even where this queue never ran. - if (!allowed) void clear().catch(() => {}) + if (!allowed) queue(clear) }) void resend() - /** Writes in flight: `stop` closes the database only after them, so a stash made on the way out lands. */ - let writes: Promise = Promise.resolve() - const stash = (signal: Signal, body: Uint8Array | undefined): void => { - writes = writes.then(() => add(signal, body)).catch(() => {}) - } - return { stashSpans: (spans) => { if (spans.length > 0) stash("traces", JsonTraceSerializer.serializeRequest(spans)) diff --git a/packages/browser/src/deferred/perf.browser.test.ts b/packages/browser/src/deferred/perf.browser.test.ts index 416056e8f..32561fc05 100644 --- a/packages/browser/src/deferred/perf.browser.test.ts +++ b/packages/browser/src/deferred/perf.browser.test.ts @@ -20,7 +20,17 @@ vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ })) const { MapleBrowser } = await import("../index") -const { onLongFrame } = await import("./perf") +const { interactionKey, onLongFrame } = await import("./perf") + +describe("interactionKey", () => { + it("skips non-interactions, groups by id, and still keys events from engines without ids", () => { + expect(interactionKey({ interactionId: 0, name: "pointermove", startTime: 10 })).toBeUndefined() + expect(interactionKey({ interactionId: 7, name: "click", startTime: 10 })).toBe( + interactionKey({ interactionId: 7, name: "pointerup", startTime: 12 }), + ) + expect(interactionKey({ name: "click", startTime: 10.4 })).toBe("click:10") + }) +}) class ScriptTimingStub { constructor( diff --git a/packages/browser/src/deferred/perf.ts b/packages/browser/src/deferred/perf.ts index 78ea3f093..9f514c7f0 100644 --- a/packages/browser/src/deferred/perf.ts +++ b/packages/browser/src/deferred/perf.ts @@ -110,6 +110,20 @@ function observe( const processingOf = (entry: PerformanceEventTiming): number => entry.processingEnd - entry.processingStart +/** + * The interaction an event belongs to. `0` means "not an interaction"; an engine + * without `interactionId` gets a per-event key, so its slow events are still spanned. + */ +export function interactionKey(entry: { + readonly interactionId?: number + readonly name: string + readonly startTime: number +}): string | undefined { + const id = entry.interactionId + if (id === 0) return undefined + return id === undefined ? `${entry.name}:${Math.round(entry.startTime)}` : `id:${id}` +} + function spanInteraction(entry: PerformanceEventTiming): void { span(`interaction ${entry.name}`, entry.startTime, entry.duration, { "maple.browser.interaction.input_delay_ms": Math.round(entry.processingStart - entry.startTime), @@ -136,20 +150,21 @@ export function startPerf(options: PerfOptions): () => void { ) } if (options.slowInteractions) { - const seen = new Set() + const seen = new Set() stops.push( observe( "event", (entries) => { // One interaction fires several events (pointerdown, pointerup, click): span it // once, named after the event whose handlers ran longest. - const byInteraction = new Map() + const byInteraction = new Map() for (const entry of entries) { - if (!(entry instanceof PerformanceEventTiming) || !entry.interactionId) continue - if (entry.duration < SLOW_INTERACTION_MS || seen.has(entry.interactionId)) continue - const best = byInteraction.get(entry.interactionId) - if (!best || processingOf(entry) > processingOf(best)) - byInteraction.set(entry.interactionId, entry) + if (!(entry instanceof PerformanceEventTiming)) continue + const key = interactionKey(entry) + if (key === undefined || entry.duration < SLOW_INTERACTION_MS || seen.has(key)) + continue + const best = byInteraction.get(key) + if (!best || processingOf(entry) > processingOf(best)) byInteraction.set(key, entry) } for (const [id, entry] of byInteraction) { seen.add(id)