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 020fe71f96..97b2459e1a 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 3e0829a865..49d598b176 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"]`. | @@ -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. | @@ -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,13 @@ 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. 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 @@ -565,7 +571,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 +651,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/identity/consent.test.ts b/packages/browser-session/src/identity/consent.test.ts index 691da117b5..35a821bed9 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 f04f3b7ed9..5a675fe2dc 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 f94fbcc4b4..462c4ad5b3 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/platform/transport.ts b/packages/browser-session/src/platform/transport.ts index 67e222cceb..d113f56344 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 7872e90dc6..00af66aa51 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 { @@ -84,6 +87,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 ab716e017f..9160564821 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 cd0aefd584..3573d49e09 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 475bc3611d..4de3e372de 100644 --- a/packages/browser-session/src/replay/record.test.ts +++ b/packages/browser-session/src/replay/record.test.ts @@ -263,6 +263,41 @@ 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() + }) + + 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 567ae9db6e..9d4a5c7f6b 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,22 @@ 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 + 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 { drain: async (keepalive = false) => { const pending = segments @@ -368,6 +388,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 9b8adb6484..3b5e068f60 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 e6238fdeca..8a0d168d44 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 cca32f7c76..f56854d490 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 8446defb2e..eef4720c1d 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 c1596c6405..cbc0e663cb 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: @@ -217,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/breadcrumbs.browser.test.ts b/packages/browser/src/breadcrumbs.browser.test.ts index 7972c70f81..ca9765bf6e 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 ca101e5054..3bdaea5559 100644 --- a/packages/browser/src/deferred/breadcrumbs.ts +++ b/packages/browser/src/deferred/breadcrumbs.ts @@ -31,6 +31,17 @@ 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" && + crumb.target !== undefined && + 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 fd8d6918f3..53cdebf23b 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 d0bd74d383..0272b3f598 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-consent-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-consent-revoked-at", String(Date.now())) + configurePrivacy({ requireConsent: true }) setConsent(true) vi.stubGlobal( "fetch", @@ -162,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 4bca3e99b7..888f21ac56 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 { 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" @@ -49,11 +43,16 @@ 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) } - return settle(request).catch(() => undefined) } export interface OfflineQueue { @@ -73,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)) @@ -88,8 +88,10 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { const read = await store("readonly") const stored = read ? (await settle(read.getAll())).filter(isStoredBatch) : [] 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()) { + // 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 > consentRevokedAt()) { const response = await fetch(`${config.endpoint}/v1/${batch.signal}`, { method: "POST", headers, @@ -132,27 +134,38 @@ 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) => { - if (!allowed) void clear().catch(() => {}) + // The consent module records the revoke itself, so it holds even where this queue never ran. + if (!allowed) queue(clear) }) void resend() 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.browser.test.ts b/packages/browser/src/deferred/perf.browser.test.ts index 416056e8ff..32561fc05c 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 e473ea657c..9f514c7f0f 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)) } @@ -109,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), @@ -135,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 === 0) 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) diff --git a/packages/browser/src/deferred/reports.ts b/packages/browser/src/deferred/reports.ts index 5ccec5d8c3..b3a57726ce 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 0a8205538c..91d971e8e3 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 fff432e35f..a4825ffe7f 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 75d8113a7e..26ccba111a 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 44d4c3b47a..699d99efc8 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 { @@ -100,9 +101,14 @@ function recordException( options: CaptureExceptionOptions, source: ErrorSource, filename?: string, + filtered = false, ): void { - if (!shouldCapture(asError(error), { source, originalError: error }, filename)) return - const span = mapleTracer(SDK_NAME, SDK_VERSION).startSpan( + 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 + const span = tracer.startSpan( options.name ?? "exception", { kind: SpanKind.INTERNAL, @@ -118,15 +124,25 @@ 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 }) +} + +/** `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/http-headers.ts b/packages/browser/src/http-headers.ts index ee7653358c..66efb5f977 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 ef26e9816b..fb40da5220 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 01a1aec07e..760da03d1b 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 fb9ee397f0..233819e6f4 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.browser.test.ts b/packages/browser/src/navigation.browser.test.ts index 2b0faed482..175f012b12 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 7ed3dafcf5..f572f83ead 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 { captureFilteredException, 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,10 +134,11 @@ 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 }) + else captureFilteredException(error, { name }) } throw error } finally { diff --git a/packages/browser/src/react.browser.test.ts b/packages/browser/src/react.browser.test.ts index e280de15ca..5af2a0e5e3 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 dc0ccb4136..5c0924ee02 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 f18b84c98a..72bdeee6f1 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -262,6 +262,41 @@ 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 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 7c9e0e172e..bb34e7fe43 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,15 @@ export function setupTracing(config: ResolvedConfig): () => Promise { setHeaderAttributes(span, "response", headers.response, (name) => result.headers.get(name), ) + } 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) } }, }), @@ -284,8 +295,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), ) }, }),