diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 7fe600c3a5..cad2d9f599 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -63,6 +63,9 @@ Every field accepted by `MapleBrowser.init`: | `errors` | `ErrorFilterOptions` | see [Filtering errors](#filtering-errors) | Drop captured errors by message, script URL, or a `beforeCapture` hook. | | `replay.enabled` | `boolean` | `true` | Enable rrweb session recording. | | `replay.sampleRate` | `number` | `1` | Fraction of sessions to record, `0` to `1`. Out-of-range values are clamped with a warning. See [Sampling](#sampling). | +| `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.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. | @@ -495,6 +498,45 @@ Sampled traces carry the W3C `tracestate` threshold (`ot=th:…`), so Maple weig inverse of the rate and request counts stay realistic. A trace joined from a server-rendered `traceparent` follows the server's decision instead. +## Request and response detail + +Headers go on the spans, as the HTTP semantic conventions define them. List the ones you want: + +```ts +MapleBrowser.init({ + // ... + tracing: { captureHeaders: { request: ["x-request-id"], response: ["x-cache", "server-timing"] } }, +}) +``` + +Each becomes a string-array attribute, e.g. `http.response.header.x-cache: ["HIT"]`. +`authorization`, `proxy-authorization`, `cookie` and `set-cookie` are never recorded, even when +listed. XHR spans get response headers only (the browser does not expose an XHR's request headers), +and a cross-origin response only exposes the headers its server lists in +`Access-Control-Expose-Headers`. + +Bodies have no semantic-convention attribute, so they stay on the session replay's network events, +and only for the URLs you list: + +```ts +replay: { + networkBodies: { + urls: [/^https:\/\/api\.example\.com\/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`, 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. + +### Canvas + +`replay: { canvasFps: 2 }` records `` content (charts, maps, games) as WebP frames at up to +that rate. It costs CPU and upload size, so it is off by default. It is never recorded with +`privacy.maskAllText`, since text drawn into a canvas cannot be masked. + ## Offline The OTLP exporters already retry a failed export a few times (about 10 seconds in all). With diff --git a/packages/browser-session/src/index.ts b/packages/browser-session/src/index.ts index 42265a7802..ed5c7beeaf 100644 --- a/packages/browser-session/src/index.ts +++ b/packages/browser-session/src/index.ts @@ -42,6 +42,7 @@ export { getObservedTraceIds, publishSessionSink, readSessionSink, recordTraceId export type { TrackProps } from "./events/track" export { track } from "./events/track" export { isLikelyBot, parseUserAgent } from "./platform/user-agent" +export type { NetworkBodyOptions } from "./platform/transport" export { ingestHeaders, SDK_HINT_HEADER, sdkHint } from "./platform/transport" export { getVisitorId, isVisitorIdPersisted, setVisitorTracking } from "./identity/visitor" export type { MapleRegion } from "./platform/region" diff --git a/packages/browser-session/src/platform/transport.ts b/packages/browser-session/src/platform/transport.ts index f1a9b24a6e..67e222cceb 100644 --- a/packages/browser-session/src/platform/transport.ts +++ b/packages/browser-session/src/platform/transport.ts @@ -19,6 +19,17 @@ export interface IngestConfig { * follow it — the same source the session metadata row reads. */ readonly getIdentity?: (() => EventIdentity | undefined) | undefined + /** Record `` content at this many frames per second. Off when unset or 0. */ + readonly canvasFps?: number | undefined + /** Keep request and response bodies of these URLs on replay network events. */ + readonly networkBodies?: NetworkBodyOptions | undefined +} + +export interface NetworkBodyOptions { + /** Matched against the full request URL. Nothing is captured for other URLs. */ + readonly urls: ReadonlyArray + /** Each body is cut to this many characters. */ + readonly maxLength: number } /** 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 ef5f08dece..7872e90dc6 100644 --- a/packages/browser-session/src/replay/capture/network.browser.test.ts +++ b/packages/browser-session/src/replay/capture/network.browser.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest" +import { afterEach, describe, expect, it, vi } from "vitest" import { noteStartedTraceId } from "../../events/trace-id" import type { SessionEvent } from "../../events/events-sink" import { installNetworkCapture } from "./network" @@ -48,4 +48,76 @@ describe("installNetworkCapture", () => { expect(network?.traceId).toBe("0af7651916cd43dd8448eb211c80319c") expect(network?.net?.method).toBe("GET") }) + + it("keeps text bodies of listed URLs only, cut to the limit", async () => { + const realFetch = window.fetch + // Under the capture, like the network would be. + window.fetch = async () => + new Response('{"order":"12345678"}', { headers: { "content-type": "application/json" } }) + try { + const events: SessionEvent[] = [] + uninstall = installNetworkCapture( + (event) => events.push(event), + () => false, + { + // Global on purpose: a stateful regex must still match every request. + urls: [/\/orders/g], + maxLength: 8, + }, + ) + await fetch("https://api.test/orders") + const response = await fetch("https://api.test/orders", { + method: "POST", + body: "request payload", + }) + expect(await response.text()).toBe('{"order":"12345678"}') + 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") + expect(listed?.attrs).toEqual({ "request.body": "request …", "response.body": '{"order"…' }) + expect(other?.attrs).toBeUndefined() + } 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 + const chunk = new TextEncoder().encode("x".repeat(1_000)) + window.fetch = async () => + new Response( + new ReadableStream({ + pull(controller) { + pulled++ + if (pulled > 1_000) controller.close() + else controller.enqueue(chunk) + }, + }), + { headers: { "content-type": "text/plain" } }, + ) + try { + const events: SessionEvent[] = [] + uninstall = installNetworkCapture( + (event) => events.push(event), + () => false, + { + urls: ["https://api.test/"], + maxLength: 2_500, + }, + ) + await fetch("https://api.test/big") + await vi.waitFor(() => expect(events.some((event) => event.type === "network")).toBe(true)) + const body = events.find((event) => event.type === "network")?.attrs?.["response.body"] ?? "" + expect(body).toHaveLength(2_501) + expect(pulled).toBeLessThan(10) + } finally { + uninstall?.() + uninstall = undefined + window.fetch = realFetch + } + }) }) diff --git a/packages/browser-session/src/replay/capture/network.ts b/packages/browser-session/src/replay/capture/network.ts index 3e7ccb3e31..ab716e017f 100644 --- a/packages/browser-session/src/replay/capture/network.ts +++ b/packages/browser-session/src/replay/capture/network.ts @@ -1,12 +1,40 @@ import { type Emit, safeEmit } from "../../capture/shared" import { activeTraceId, withStartedTraceId } from "../../events/trace-id" +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 + +const matchesUrl = (url: string, patterns: ReadonlyArray): boolean => + patterns.some((pattern) => { + if (typeof pattern === "string") return url.includes(pattern) + // A `g`/`y` regex is stateful: `test` advances `lastIndex`, so reset it first. + pattern.lastIndex = 0 + return pattern.test(url) + }) + +const cut = (text: string, maxLength: number): string => + text.length > maxLength ? `${text.slice(0, maxLength)}…` : text /** * Capture fetch + XHR requests as session events, tagged with the active trace * id so each request links to its backend trace. `ignoreUrl` skips Maple's own * ingest endpoints (otherwise capturing the session-events POST would loop). + * `bodies`, when set, keeps text request/response bodies of the URLs it lists. */ -export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => boolean): () => void { +export function installNetworkCapture( + emit: Emit, + ignoreUrl: (url: string) => boolean, + bodies?: NetworkBodyOptions, +): () => void { + const wantsBody = (url: string): boolean => bodies !== undefined && matchesUrl(url, bodies.urls) + const bodyAttrs = ( + request: string | undefined, + response: string | undefined, + ): Record => ({ + ...(request && bodies ? { "request.body": cut(request, bodies.maxLength) } : undefined), + ...(response && bodies ? { "response.body": cut(response, bodies.maxLength) } : undefined), + }) const origFetch = typeof window !== "undefined" ? window.fetch : undefined if (origFetch) { @@ -22,7 +50,32 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo const call = withStartedTraceId(() => origFetch(input, init)) traceId = call.traceId ?? ambientTraceId const res = await call.result - record(url, method, res.status, start, traceId) + if (!wantsBody(url)) { + record(url, method, res.status, start, traceId) + return res + } + // Read a clone in the background: the app gets its response untouched and unwaited. + const requestBody = typeof init?.body === "string" ? init.body : undefined + const done = performance.now() + const contentType = res.headers.get("content-type") ?? "" + void ( + TEXT_CONTENT.test(contentType) + ? readPrefix(res.clone(), bodies?.maxLength ?? 0) + : Promise.resolve(undefined) + ) + .catch(() => undefined) + .then((responseBody) => + record( + url, + method, + res.status, + start, + traceId, + undefined, + bodyAttrs(requestBody, responseBody), + done, + ), + ) return res } catch (error) { record(url, method, 0, start, traceId, String(error)) @@ -38,13 +91,16 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo start: number, traceId: string | undefined, error?: string, + extra?: Record, + end = performance.now(), ): void => { if (ignoreUrl(url)) return + const attrs = { ...extra, ...(error ? { error } : undefined) } safeEmit(emit, { type: "network", - net: { method, url, status, durationMs: Math.round(performance.now() - start) }, + net: { method, url, status, durationMs: Math.round(end - start) }, traceId, - ...(error ? { attrs: { error } } : undefined), + ...(Object.keys(attrs).length > 0 ? { attrs } : undefined), }) } @@ -70,8 +126,11 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo const meta = this as XhrMeta const start = performance.now() let traceId = meta.__mapleTraceId ?? activeTraceId() + const url = meta.__mapleUrl ?? "" + const requestBody = typeof args[0] === "string" ? args[0] : undefined this.addEventListener("loadend", () => { - record(meta.__mapleUrl ?? "", meta.__mapleMethod ?? "GET", this.status, start, traceId) + const extra = wantsBody(url) ? bodyAttrs(requestBody, xhrResponseText(this)) : undefined + record(url, meta.__mapleMethod ?? "GET", this.status, start, traceId, undefined, extra) }) const call = withStartedTraceId(() => origSend.apply(this, args as never)) traceId = call.traceId ?? traceId @@ -86,6 +145,38 @@ export function installNetworkCapture(emit: Emit, ignoreUrl: (url: string) => bo } } +/** + * Up to `maxLength` characters of a response body (one more, so `cut` marks it + * cut), then the stream is cancelled: a large payload is never read in full. + */ +async function readPrefix(response: Response, maxLength: number): Promise { + const reader = response.body?.getReader() + if (!reader) return undefined + const decoder = new TextDecoder() + let text = "" + while (text.length <= maxLength) { + const { done, value } = await reader.read() + if (done) return text + decoder.decode() + text += decoder.decode(value, { stream: true }) + } + void reader.cancel().catch(() => {}) + return text +} + +/** 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 (xhr.responseType === "" || xhr.responseType === "text") return xhr.responseText + if (xhr.responseType === "json") { + try { + return JSON.stringify(xhr.response) + } catch { + return undefined + } + } + return undefined +} + interface XhrMeta extends XMLHttpRequest { __mapleMethod?: string __mapleUrl?: string diff --git a/packages/browser-session/src/replay/events.ts b/packages/browser-session/src/replay/events.ts index a573b94bdb..cd0aefd584 100644 --- a/packages/browser-session/src/replay/events.ts +++ b/packages/browser-session/src/replay/events.ts @@ -31,7 +31,10 @@ export function startEventCapture(config: IngestConfig, sessionId: string): Even const sink: SessionEventSink = startEventSink(config, sessionId) const emit = sink.emit - const uninstall = [installConsoleCapture(emit), installNetworkCapture(emit, sink.ignoreUrl)] + const uninstall = [ + installConsoleCapture(emit), + installNetworkCapture(emit, sink.ignoreUrl, config.maskAllText ? undefined : config.networkBodies), + ] return { // Only the capture listeners stop here — the sink outlives them, so diff --git a/packages/browser-session/src/replay/record.test.ts b/packages/browser-session/src/replay/record.test.ts index b329bd3e19..475bc3611d 100644 --- a/packages/browser-session/src/replay/record.test.ts +++ b/packages/browser-session/src/replay/record.test.ts @@ -10,9 +10,11 @@ let emitRef: EmitFn | undefined const takeFullSnapshot = vi.fn() const stopFn = vi.fn() +let recordOptions: Record | undefined vi.mock("rrweb", () => { - const record = (options: { emit: EmitFn }) => { + const record = (options: { emit: EmitFn } & Record) => { emitRef = options.emit + recordOptions = options return stopFn } record.takeFullSnapshot = takeFullSnapshot @@ -262,3 +264,14 @@ describe("startBufferedRecording", () => { expect(stopFn).toHaveBeenCalled() }) }) + +describe("canvas capture", () => { + it("is off by default and samples frames at canvasFps when asked", () => { + startRecording(CONFIG, "session-1").stop() + expect(recordOptions?.recordCanvas).toBeUndefined() + startBufferedRecording({ ...CONFIG, canvasFps: 2 }, "session-1").stop() + expect(recordOptions).toMatchObject({ recordCanvas: true, sampling: { canvas: 2 } }) + startRecording({ ...CONFIG, canvasFps: 2, maskAllText: true }, "session-1").stop() + expect(recordOptions?.recordCanvas).toBeUndefined() + }) +}) diff --git a/packages/browser-session/src/replay/record.ts b/packages/browser-session/src/replay/record.ts index 1f181d1e52..567ae9db6e 100644 --- a/packages/browser-session/src/replay/record.ts +++ b/packages/browser-session/src/replay/record.ts @@ -207,6 +207,7 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde // rrweb has no `maskAllText` flag; selecting all elements masks every text node. ...(config.maskAllText ? { maskTextSelector: "*" } : undefined), checkoutEveryNms: CHECKOUT_EVERY_MS, + ...canvasOptions(config), }) // The periodic flush yields to idle time so it never competes with an @@ -261,6 +262,17 @@ export function startRecording(config: IngestConfig, sessionId: string): Recorde } } +/** rrweb options for `` capture: sampled frames as WebP, off unless asked for. */ +function canvasOptions(config: IngestConfig) { + // Canvas pixels can carry text (chart labels, grids) that maskAllText cannot reach. + if (config.maskAllText || !config.canvasFps || config.canvasFps <= 0) return undefined + return { + recordCanvas: true, + sampling: { canvas: config.canvasFps }, + dataURLOptions: { type: "image/webp", quality: 0.6 }, + } +} + /** Buffer mode checks out often, so the retained window stays near a minute. */ const BUFFER_CHECKOUT_MS = 30_000 @@ -327,6 +339,7 @@ export function startBufferedRecording(config: IngestConfig, sessionId: string): blockSelector: BLOCK_SELECTOR, ...(config.maskAllText ? { maskTextSelector: "*" } : undefined), checkoutEveryNms: BUFFER_CHECKOUT_MS, + ...canvasOptions(config), }) return { diff --git a/packages/browser-session/src/session/replay-session.ts b/packages/browser-session/src/session/replay-session.ts index 0ce3aafd8c..e6238fdeca 100644 --- a/packages/browser-session/src/session/replay-session.ts +++ b/packages/browser-session/src/session/replay-session.ts @@ -14,7 +14,7 @@ import { startBufferedRecording, startRecording, } from "../replay/record" -import { postSessionMeta, type IngestConfig } from "../platform/transport" +import { type IngestConfig, type NetworkBodyOptions, postSessionMeta } from "../platform/transport" import { type SessionLifecycleHandle, type SessionLifecycleOptions, startSessionLifecycle } from "./lifecycle" import { markReplayTriggered } from "./session" import { getObservedTraceIds, publishSessionSink } from "./sink" @@ -28,6 +28,10 @@ export interface ReplaySessionOptions extends SessionLifecycleOptions { readonly sdk: string readonly maskAllInputs: boolean readonly maskAllText: boolean + /** Record `` content at this many frames per second. Off when unset or 0. */ + readonly canvasFps?: number | undefined + /** Keep request and response bodies of these URLs on replay network events. */ + readonly networkBodies?: NetworkBodyOptions | undefined /** Notifies consumers that the session id used for span linking changed. */ readonly onSessionChange?: ((sessionId: string) => void) | undefined /** @@ -65,6 +69,8 @@ export function startReplaySession(options: ReplaySessionOptions): ReplaySession maskAllInputs: options.maskAllInputs, maskAllText: options.maskAllText, getIdentity: options.getIdentity, + canvasFps: options.canvasFps, + networkBodies: options.networkBodies, } let recorder: Recorder | undefined diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index cbac0a0cb8..b5dcbec7fa 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -24,14 +24,15 @@ import { gzipSync } from "node:zlib" /** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */ const BUDGET = { /** - * 43.5 since 2026-09: the offline queue's exporter wrapper (~0.2 kB). 43: XHR spans and the HTTP status policy, which must patch + * 44 since 2026-09: `tracing.captureHeaders` in the request hooks (~0.3 kB). + * 43.5: the offline queue's exporter wrapper (~0.2 kB). 43: XHR spans and the HTTP status policy, which must patch * before the app's first request (~1.5 kB). Document timing went to the * deferred chunk instead. 42: error filters and cause chains. 41 before that: * per-session trace sampling and the `logger` queue added ~2.4 kB (~1.2 kB * code, the rest chunk-split overhead now that a second chunk shares the OTel * core). Was 38 for navigation spans. */ - eager: 43.5, + eager: 44, /** * Every page load, after `init()`: the OTel logs SDK and exporter, document * timing, and `web-vitals` (~3.3 kB, 8 -> 12). @@ -64,9 +65,9 @@ const BUDGET = { * (~0.5 kB), both needed before the deferred chunk lands. 17 for error * filters and cause chains (~0.8 kB), which run on the capture path. 17.5 * for `errors.captureHttpStatus`, applied by the span exporter. 18 for the - * offline queue's exporter wrapper. + * offline queue's exporter wrapper, 18.5 for `tracing.captureHeaders`. */ - firstParty: 18, + firstParty: 18.5, } /** How close to a ceiling counts as worth warning about. */ diff --git a/packages/browser/src/config.test.ts b/packages/browser/src/config.test.ts index db4a332c51..507f458db0 100644 --- a/packages/browser/src/config.test.ts +++ b/packages/browser/src/config.test.ts @@ -119,4 +119,30 @@ describe("resolveConfig", () => { expect(warn).toHaveBeenCalledTimes(2) warn.mockRestore() }) + + it("drops credential headers from captureHeaders, lowercased", () => { + const config = resolveConfig({ + ingestKey: "k", + serviceName: "s", + tracing: { + captureHeaders: { + request: ["Authorization", "X-Request-Id"], + response: ["Set-Cookie", "X-Cache"], + }, + }, + }) + expect(config.captureHeaders).toEqual({ request: ["x-request-id"], response: ["x-cache"] }) + }) + + it("caps network body length at what ingest keeps", () => { + const resolve = (maxLength?: number) => + resolveConfig({ + ingestKey: "k", + serviceName: "s", + replay: { networkBodies: { urls: ["/api"], maxLength } }, + }).networkBodies?.maxLength + expect(resolve()).toBe(1_000) + expect(resolve(200)).toBe(200) + expect(resolve(50_000)).toBe(1_000) + }) }) diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts index 14d0e3182a..9ca8c820bb 100644 --- a/packages/browser/src/config.ts +++ b/packages/browser/src/config.ts @@ -8,6 +8,10 @@ import { warnIfKeylessMapleIngest, } from "@maple/browser-session" import type { ErrorFilterOptions } from "./error-filters" +import { type HeaderCapture, resolveHeaderCapture } from "./http-headers" + +/** Ingest keeps 1,024 bytes of a session-event attribute; this leaves room for the cut marker. */ +const MAX_BODY_LENGTH = 1_000 export type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error" @@ -81,6 +85,16 @@ export interface MapleBrowserConfig { * always exported. */ readonly sampleRate?: number + /** + * Request and response headers to record on `fetch`/XHR spans, as + * `http.request.header.` / `http.response.header.`, e.g. + * `{ response: ["x-request-id", "x-cache"] }`. `authorization`, `cookie` + * and `set-cookie` are never recorded. XHR spans get response headers only. + */ + readonly captureHeaders?: { + readonly request?: ReadonlyArray + readonly response?: ReadonlyArray + } } /** * Report Core Web Vitals (LCP, CLS, INP, FCP, TTFB) as `browser.web_vital` @@ -123,6 +137,22 @@ export interface MapleBrowserConfig { * error happens. 0–1, default 0. */ readonly onErrorSampleRate?: number + /** + * Record `` content at this many frames per second, e.g. 2. Off by + * default: it is heavy. Never with `privacy.maskAllText`, since canvas pixels can hold text. + */ + readonly canvasFps?: number + /** + * Keep request and response bodies (text and JSON only) on the replay's + * network events for these URLs, cut to `maxLength` characters. Ingest + * keeps at most 1,024 bytes of each, so `maxLength` is capped at 1,000 + * (the default). Nothing is captured for other URLs, or with + * `privacy.maskAllText`. + */ + readonly networkBodies?: { + readonly urls: ReadonlyArray + readonly maxLength?: number + } } readonly privacy?: { /** Mask all `` values. Default true. */ @@ -193,6 +223,11 @@ export interface ResolvedConfig { readonly replayEnabled: boolean readonly replaySampleRate: number readonly replayOnErrorSampleRate: number + readonly canvasFps: number | undefined + readonly networkBodies: + | { readonly urls: ReadonlyArray; readonly maxLength: number } + | undefined + readonly captureHeaders: HeaderCapture readonly maskAllInputs: boolean readonly maskAllText: boolean readonly persistVisitorId: boolean @@ -264,6 +299,17 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig { offlineQueue: config.transport?.offline ?? false, replayEnabled: config.replay?.enabled ?? true, replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate), + canvasFps: config.replay?.canvasFps, + networkBodies: config.replay?.networkBodies?.urls.length + ? { + urls: config.replay.networkBodies.urls, + maxLength: Math.min( + MAX_BODY_LENGTH, + config.replay.networkBodies.maxLength ?? MAX_BODY_LENGTH, + ), + } + : undefined, + captureHeaders: resolveHeaderCapture(config.tracing?.captureHeaders), replayOnErrorSampleRate: config.replay?.onErrorSampleRate === undefined ? 0 diff --git a/packages/browser/src/http-headers.ts b/packages/browser/src/http-headers.ts new file mode 100644 index 0000000000..ee7653358c --- /dev/null +++ b/packages/browser/src/http-headers.ts @@ -0,0 +1,34 @@ +// Allowlisted request/response headers as the HTTP semconv span attributes +// `http.request.header.` / `http.response.header.` (string arrays). +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"]) + +export interface HeaderCapture { + readonly request: ReadonlyArray + readonly response: ReadonlyArray +} + +export function resolveHeaderCapture( + raw: { readonly request?: ReadonlyArray; readonly response?: ReadonlyArray } | undefined, +): HeaderCapture { + const clean = (names: ReadonlyArray | undefined) => + (names ?? []).map((name) => name.toLowerCase()).filter((name) => !CREDENTIAL_HEADERS.has(name)) + return { request: clean(raw?.request), response: clean(raw?.response) } +} + +/** 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, + direction: "request" | "response", + names: ReadonlyArray, + read: (name: string) => string | null | undefined, +): void { + for (const name of names) { + const value = read(name) + // One entry: commas are part of many values (`date`, `cache-control`), and the + // browser has already joined repeated headers into one string. + if (value) span.setAttribute(`http.${direction}.header.${name}`, [value]) + } +} diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 2f0f7285bb..fb9ee397f0 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -183,6 +183,8 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { maskAllInputs: config.maskAllInputs, maskAllText: config.maskAllText, mode: replayMode, + canvasFps: config.canvasFps, + networkBodies: config.networkBodies, }) }) .catch(() => { diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts index cf08b6f6c8..d215ad35db 100644 --- a/packages/browser/src/navigation.test.ts +++ b/packages/browser/src/navigation.test.ts @@ -36,6 +36,9 @@ const CONFIG = { replayEnabled: false, replaySampleRate: 0, replayOnErrorSampleRate: 0, + canvasFps: undefined, + networkBodies: undefined, + captureHeaders: { request: [], response: [] }, maskAllInputs: true, maskAllText: false, persistVisitorId: true, diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts index f8b19ac41d..cb57677ffa 100644 --- a/packages/browser/src/tracing.browser.test.ts +++ b/packages/browser/src/tracing.browser.test.ts @@ -45,6 +45,9 @@ const CONFIG = { replayEnabled: false, replaySampleRate: 0, replayOnErrorSampleRate: 0, + canvasFps: undefined, + networkBodies: undefined, + captureHeaders: { request: [], response: [] }, maskAllInputs: true, maskAllText: false, persistVisitorId: true, @@ -233,6 +236,30 @@ describe("setupTracing unload flush", () => { URL.revokeObjectURL(url) }) + it("records allowlisted headers as semconv attributes, never credentials", async () => { + vi.useFakeTimers({ toFake: ["setTimeout"] }) + const poll = { interval: 0 } + shutdown = setupTracing({ + ...CONFIG, + tracingInstrumentFetch: true, + captureHeaders: { request: ["x-request-id"], response: ["content-type"] }, + }) + const url = URL.createObjectURL(new Blob(["ok"], { type: "text/plain" })) + + await ( + await fetch(url, { headers: { "x-request-id": "req-1", authorization: "Bearer secret" } }) + ).text() + await vi.waitFor(() => expect(vi.getTimerCount()).toBe(1), poll) + window.dispatchEvent(new Event("pagehide")) + await vi.waitFor(() => expect(exported).toHaveLength(1), poll) + + const attributes = exported[0]?.attributes ?? {} + expect(attributes["http.request.header.x-request-id"]).toEqual(["req-1"]) + expect(attributes["http.response.header.content-type"]).toEqual(["text/plain"]) + expect(Object.keys(attributes).some((key) => key.includes("authorization"))).toBe(false) + 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 6c8fc8d6ea..7c9e0e172e 100644 --- a/packages/browser/src/tracing.ts +++ b/packages/browser/src/tracing.ts @@ -25,6 +25,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 { HttpStatusExporter } from "./http-status" import { OfflineSpanExporter } from "./offline" import { SessionSampler } from "./sampling" @@ -256,11 +257,40 @@ export function setupTracing(config: ResolvedConfig): () => Promise { // `traceparent` goes to same-origin requests only, unless the app lists // the cross-origin APIs that accept it. propagateTraceHeaderCorsUrls: [...config.propagateTraceHeaderCorsUrls], - applyCustomAttributesOnSpan: noteSettled, } + const headers = config.captureHeaders const instrumentations = [ - ...(config.tracingInstrumentFetch ? [new FetchInstrumentation(requestOptions)] : []), - ...(config.tracingInstrumentXhr ? [new XMLHttpRequestInstrumentation(requestOptions)] : []), + ...(config.tracingInstrumentFetch + ? [ + new FetchInstrumentation({ + ...requestOptions, + applyCustomAttributesOnSpan: (span, request, result) => { + noteSettled(span) + const sent = + request instanceof Request ? request.headers : new Headers(request.headers) + setHeaderAttributes(span, "request", headers.request, (name) => sent.get(name)) + if (result instanceof Response) { + setHeaderAttributes(span, "response", headers.response, (name) => + result.headers.get(name), + ) + } + }, + }), + ] + : []), + ...(config.tracingInstrumentXhr + ? [ + new XMLHttpRequestInstrumentation({ + ...requestOptions, + applyCustomAttributesOnSpan: (span, xhr) => { + noteSettled(span) + setHeaderAttributes(span, "response", headers.response, (name) => + xhr.getResponseHeader(name), + ) + }, + }), + ] + : []), ] const unregisterInstrumentations = instrumentations.length > 0