Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 10 additions & 0 deletions docs/browser-sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Every field accepted by `MapleBrowser.init`:
| `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). |
| `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 `<input>` values in the recording. |
| `privacy.maskAllText` | `boolean` | `false` | Mask all text in the rrweb recording and omit captured click-target text from session events. |
| `privacy.persistVisitorId` | `boolean` | `true` | Store a persistent visitor id (localStorage + cookie) so unique visitors and new-vs-returning are measurable. Turning it off also purges any id already stored. |
Expand Down Expand Up @@ -494,6 +495,15 @@ 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.

## Offline

The OTLP exporters already retry a failed export a few times (about 10 seconds in all). With
`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
windows), nothing is kept.

## Framework examples

### Plain HTML
Expand Down
1 change: 1 addition & 0 deletions packages/browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@opentelemetry/instrumentation": "^0.222.0",
"@opentelemetry/instrumentation-fetch": "^0.222.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.222.0",
"@opentelemetry/otlp-transformer": "^0.222.0",
"@opentelemetry/resources": "^2.10.0",
"@opentelemetry/sdk-logs": "^0.222.0",
"@opentelemetry/sdk-trace-base": "^2.10.0",
Expand Down
9 changes: 5 additions & 4 deletions packages/browser/scripts/size.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ import { gzipSync } from "node:zlib"
/** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */
const BUDGET = {
/**
* 43 since 2026-09: XHR spans and the HTTP status policy, which must patch
* 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
* 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,
eager: 43.5,
/**
* Every page load, after `init()`: the OTel logs SDK and exporter, document
* timing, and `web-vitals` (~3.3 kB, 8 -> 12).
Expand Down Expand Up @@ -63,9 +63,10 @@ const BUDGET = {
* 16 since 2026-09: the session sampler (~0.7 kB) and the `logger` queue
* (~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.
* for `errors.captureHttpStatus`, applied by the span exporter. 18 for the
* offline queue's exporter wrapper.
*/
firstParty: 17.5,
firstParty: 18,
}

/** How close to a ceiling counts as worth warning about. */
Expand Down
10 changes: 10 additions & 0 deletions packages/browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,14 @@ export interface MapleBrowserConfig {
/** Browser deprecation and intervention reports as `maple.browser.report` WARN logs. Default false. */
readonly browserReports?: boolean
}
readonly transport?: {
/**
* Keep span and log batches that could not be sent (the browser was
* offline) in IndexedDB for up to 24 hours, and send them once it is back
* online or on the next page load. Default false.
*/
readonly offline?: boolean
}
/** Which captured errors to drop before they are reported. See `ErrorFilterOptions`. */
readonly errors?: ErrorFilterOptions
readonly replay?: {
Expand Down Expand Up @@ -181,6 +189,7 @@ export interface ResolvedConfig {
readonly captureConsole: ReadonlyArray<ConsoleLevel>
readonly reportCsp: boolean
readonly reportBrowser: boolean
readonly offlineQueue: boolean
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly replayOnErrorSampleRate: number
Expand Down Expand Up @@ -252,6 +261,7 @@ export function resolveConfig(config: MapleBrowserConfig): ResolvedConfig {
captureConsole: config.logs?.captureConsole ?? [],
reportCsp: config.reporting?.csp ?? true,
reportBrowser: config.reporting?.browserReports ?? false,
offlineQueue: config.transport?.offline ?? false,
replayEnabled: config.replay?.enabled ?? true,
replaySampleRate: resolveSampleRate("replay.sampleRate", config.replay?.sampleRate),
replayOnErrorSampleRate:
Expand Down
8 changes: 7 additions & 1 deletion packages/browser/src/deferred/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ import type { SpanContext } from "@opentelemetry/api"
import type { ResolvedConfig } from "../config"
import { onErrorRecorded } from "../errors"
import { onDocumentPageload } from "../navigation"
import { attachSpanStash } from "../offline"
import { flushBreadcrumbs, startBreadcrumbs } from "./breadcrumbs"
import { recordDocumentTiming } from "./document-timing"
import { startLogs } from "./logs"
import { startOfflineQueue } from "./offline"
import { startReports } from "./reports"
import { startWebVitals } from "./web-vitals"

Expand All @@ -24,14 +26,18 @@ export function startDeferred(config: ResolvedConfig): () => Promise<void> {
})
const stopErrorListener = onErrorRecorded(flushBreadcrumbs)
const stopReports = startReports({ csp: config.reportCsp, browserReports: config.reportBrowser })
const offline = config.offlineQueue ? startOfflineQueue(config) : undefined
attachSpanStash(offline?.stashSpans)
const stops = [
startLogs(config),
startLogs(config, offline?.stashLogs),
async () => {
stopVitals()
onDocumentPageload(undefined)
stopErrorListener()
stopBreadcrumbs()
stopReports()
attachSpanStash(undefined)
offline?.stop()
},
]
return async () => {
Expand Down
39 changes: 32 additions & 7 deletions packages/browser/src/deferred/logs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,39 @@ class ConsentLogExporter implements LogRecordExporter {
}
}

/** Hands batches the exporter gave up on to the offline queue. */
class OfflineLogExporter implements LogRecordExporter {
constructor(
private readonly inner: LogRecordExporter,
private readonly stash: (logs: ReadableLogRecord[]) => void,
) {}

export(logs: ReadableLogRecord[], callback: (result: { code: number; error?: Error }) => void): void {
this.inner.export(logs, (result) => {
if (result.code !== 0) this.stash(logs)
callback(result)
})
}

forceFlush(): Promise<void> {
return this.inner.forceFlush?.() ?? Promise.resolve()
}

shutdown(): Promise<void> {
return this.inner.shutdown()
}
}

/** Start the OTel logs pipeline and drain the eager queue into it. Returns a shutdown. */
export function startLogs(config: ResolvedConfig): () => Promise<void> {
const exporter = new ConsentLogExporter(
new OTLPLogExporter({
url: `${config.endpoint}/v1/logs`,
headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }),
}),
)
export function startLogs(
config: ResolvedConfig,
stashOffline?: (logs: ReadableLogRecord[]) => void,
): () => Promise<void> {
const otlp = new OTLPLogExporter({
url: `${config.endpoint}/v1/logs`,
headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }),
})
const exporter = new ConsentLogExporter(stashOffline ? new OfflineLogExporter(otlp, stashOffline) : otlp)
// The browser processor flushes on `visibilitychange → hidden` and `pagehide` itself.
const provider = new LoggerProvider({
resource: resourceFromAttributes(resourceAttributes(config)),
Expand Down
182 changes: 182 additions & 0 deletions packages/browser/src/deferred/offline.browser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
import { configurePrivacy, resetConsentForTests, setConsent } from "@maple/browser-session"
import {
BasicTracerProvider,
InMemorySpanExporter,
type ReadableSpan,
SimpleSpanProcessor,
} from "@opentelemetry/sdk-trace-base"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
import { resolveConfig } from "../config"
import { attachSpanStash, OfflineSpanExporter, resetOfflineForTests } from "../offline"
import { startOfflineQueue } from "./offline"

const CONFIG = resolveConfig({ ingestKey: "k", serviceName: "web", endpoint: "https://ingest.test" })

const finishedSpans = (...names: string[]): ReadableSpan[] => {
const memory = new InMemorySpanExporter()
const tracer = new BasicTracerProvider({ spanProcessors: [new SimpleSpanProcessor(memory)] }).getTracer(
"t",
)
for (const name of names) tracer.startSpan(name).end()
return memory.getFinishedSpans()
}

/** How many batches the queue holds, read straight from IndexedDB. */
const storedCount = async (): Promise<number> => {
const db = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open("maple-offline", 1)
request.onupgradeneeded = () =>
request.result.createObjectStore("batches", { keyPath: "id", autoIncrement: true })
request.onsuccess = () => resolve(request.result)
request.onerror = () => reject(request.error)
})
const count = await new Promise<number>((resolve) => {
const request = db.transaction("batches").objectStore("batches").count()
request.onsuccess = () => resolve(request.result)
})
db.close()
return count
}

let stop: (() => void) | undefined

beforeEach(async () => {
await new Promise((resolve) => {
const request = indexedDB.deleteDatabase("maple-offline")
request.onsuccess = request.onerror = request.onblocked = () => resolve(undefined)
})
})

afterEach(() => {
stop?.()
stop = undefined
resetOfflineForTests()
resetConsentForTests()
vi.unstubAllGlobals()
})

describe("offline queue", () => {
it("stores batches the exporter gave up on and sends them once back online", async () => {
const posts: Array<{ url: string; body: string }> = []
let online = false
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init: RequestInit) => {
if (!online) throw new TypeError("Failed to fetch")
posts.push({ url, body: new TextDecoder().decode(init.body as Uint8Array) })
return new Response("{}")
}),
)
const queue = startOfflineQueue(CONFIG)
stop = queue.stop
queue.stashSpans(finishedSpans("checkout"))
await vi.waitFor(async () => expect(await storedCount()).toBe(1))

online = true
window.dispatchEvent(new Event("online"))
await vi.waitFor(async () => expect(await storedCount()).toBe(0))
expect(posts).toHaveLength(1)
expect(posts[0]?.url).toBe("https://ingest.test/v1/traces")
expect(JSON.parse(posts[0]?.body ?? "{}").resourceSpans[0].scopeSpans[0].spans[0].name).toBe(
"checkout",
)
})

it("keeps batches while ingest is failing, and drops ones it rejects", async () => {
const statuses = [503, 400]
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response("{}", { status: statuses.shift() ?? 200 })),
)
const queue = startOfflineQueue(CONFIG)
stop = queue.stop
await queue.resend()
queue.stashLogs([])
queue.stashSpans(finishedSpans("a"))
await vi.waitFor(async () => expect(await storedCount()).toBe(1))
await queue.resend()
expect(await storedCount()).toBe(1)
await queue.resend()
expect(await storedCount()).toBe(0)
})
})

describe("offline queue across tabs", () => {
it("sends each stored batch once when two tabs resend at the same time", 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)
await new Promise((resolve) => setTimeout(resolve, 20))
return new Response("{}")
}),
)
// Two queues on one origin stand in for two tabs: they share the store and the lock.
const tabA = startOfflineQueue(CONFIG)
const tabB = startOfflineQueue(CONFIG)
tabA.stashSpans(finishedSpans("once"))
await vi.waitFor(async () => expect(await storedCount()).toBe(1))
online = true
await Promise.all([tabA.resend(), tabB.resend()])
tabA.stop()
tabB.stop()
expect(posts).toHaveLength(1)
expect(await storedCount()).toBe(0)
})
})

describe("offline queue and consent", () => {
it("drops batches captured before the current consent grant instead of sending them", async () => {
const posts: string[] = []
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
posts.push(url)
return new Response("{}", { status: 503 })
}),
)
const first = startOfflineQueue(CONFIG)
// Let its startup resend (of an empty store) finish before anything is stored.
await first.resend()
first.stashSpans(finishedSpans("before"))
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 })
await new Promise((resolve) => setTimeout(resolve, 5))
setConsent(true)
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
posts.push(url)
return new Response("{}")
}),
)
const second = startOfflineQueue(CONFIG)
stop = second.stop
await second.resend()
await vi.waitFor(async () => expect(await storedCount()).toBe(0))
expect(posts.filter((url) => url.endsWith("/v1/traces"))).toEqual([])
})
})

describe("OfflineSpanExporter", () => {
it("hands failed batches to the stash, holding them until it is attached", () => {
const failing = {
export: (_spans: ReadableSpan[], callback: (result: { code: number }) => void) =>
callback({ code: 1 }),
shutdown: async () => {},
}
const exporter = new OfflineSpanExporter(failing)
const spans = finishedSpans("early")
exporter.export(spans, () => {})
const stashed: ReadableSpan[][] = []
attachSpanStash((batch) => stashed.push(batch))
exporter.export(finishedSpans("late"), () => {})
expect(stashed.map((batch) => batch[0]?.name)).toEqual(["early", "late"])
})
})
Loading
Loading