diff --git a/bun.lock b/bun.lock
index dff6c2859..2ee3f61ad 100644
--- a/bun.lock
+++ b/bun.lock
@@ -647,6 +647,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",
diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md
index 2429b849d..7fe600c3a 100644
--- a/docs/browser-sdk.md
+++ b/docs/browser-sdk.md
@@ -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 `` 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. |
@@ -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
diff --git a/packages/browser/package.json b/packages/browser/package.json
index 22470bc73..3bde1201b 100644
--- a/packages/browser/package.json
+++ b/packages/browser/package.json
@@ -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",
diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts
index 9ed05a7f6..cbac0a0cb 100644
--- a/packages/browser/scripts/size.ts
+++ b/packages/browser/scripts/size.ts
@@ -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).
@@ -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. */
diff --git a/packages/browser/src/config.ts b/packages/browser/src/config.ts
index bab0b2299..14d0e3182 100644
--- a/packages/browser/src/config.ts
+++ b/packages/browser/src/config.ts
@@ -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?: {
@@ -181,6 +189,7 @@ export interface ResolvedConfig {
readonly captureConsole: ReadonlyArray
readonly reportCsp: boolean
readonly reportBrowser: boolean
+ readonly offlineQueue: boolean
readonly replayEnabled: boolean
readonly replaySampleRate: number
readonly replayOnErrorSampleRate: number
@@ -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:
diff --git a/packages/browser/src/deferred/index.ts b/packages/browser/src/deferred/index.ts
index f6927ccc1..7d04b2d78 100644
--- a/packages/browser/src/deferred/index.ts
+++ b/packages/browser/src/deferred/index.ts
@@ -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"
@@ -24,14 +26,18 @@ export function startDeferred(config: ResolvedConfig): () => Promise {
})
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 () => {
diff --git a/packages/browser/src/deferred/logs.ts b/packages/browser/src/deferred/logs.ts
index df33b7140..42a9726b0 100644
--- a/packages/browser/src/deferred/logs.ts
+++ b/packages/browser/src/deferred/logs.ts
@@ -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 {
+ return this.inner.forceFlush?.() ?? Promise.resolve()
+ }
+
+ shutdown(): Promise {
+ 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 {
- 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 {
+ 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)),
diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/browser/src/deferred/offline.browser.test.ts
new file mode 100644
index 000000000..d0bd74d38
--- /dev/null
+++ b/packages/browser/src/deferred/offline.browser.test.ts
@@ -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 => {
+ const db = await new Promise((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((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"])
+ })
+})
diff --git a/packages/browser/src/deferred/offline.ts b/packages/browser/src/deferred/offline.ts
new file mode 100644
index 000000000..4bca3e99b
--- /dev/null
+++ b/packages/browser/src/deferred/offline.ts
@@ -0,0 +1,158 @@
+// Batches the exporters gave up on, stored in IndexedDB as the same OTLP JSON
+// 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 { JsonLogsSerializer, JsonTraceSerializer } from "@opentelemetry/otlp-transformer"
+import type { ReadableLogRecord } from "@opentelemetry/sdk-logs"
+import type { ReadableSpan } from "@opentelemetry/sdk-trace-base"
+import type { ResolvedConfig } from "../config"
+import { SDK_NAME, SDK_VERSION } from "../version"
+
+const DB_NAME = "maple-offline"
+const STORE = "batches"
+const MAX_AGE_MS = 24 * 60 * 60 * 1_000
+const MAX_BATCHES = 100
+
+type Signal = "traces" | "logs"
+
+interface StoredBatch {
+ readonly id?: number
+ readonly signal: Signal
+ readonly body: Uint8Array
+ readonly createdAt: number
+}
+
+const isStoredBatch = (value: unknown): value is StoredBatch & { readonly id: number } =>
+ typeof value === "object" &&
+ value !== null &&
+ "id" in value &&
+ typeof value.id === "number" &&
+ "signal" in value &&
+ (value.signal === "traces" || value.signal === "logs") &&
+ "body" in value &&
+ value.body instanceof Uint8Array &&
+ "createdAt" in value &&
+ typeof value.createdAt === "number"
+
+const settle = (request: IDBRequest): Promise =>
+ new Promise((resolve, reject) => {
+ request.onsuccess = () => resolve(request.result)
+ request.onerror = () => reject(request.error)
+ })
+
+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 })
+ }
+ return settle(request).catch(() => undefined)
+}
+
+export interface OfflineQueue {
+ readonly stashSpans: (spans: ReadableSpan[]) => void
+ readonly stashLogs: (logs: ReadableLogRecord[]) => void
+ /** Send what is stored, oldest first. Stops at the first failure. */
+ readonly resend: () => Promise
+ readonly stop: () => void
+}
+
+export function startOfflineQueue(config: ResolvedConfig): OfflineQueue {
+ const db = openDb()
+ const headers = {
+ ...ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }),
+ "Content-Type": "application/json",
+ }
+ 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 batches = await store("readwrite")
+ if (!batches) return
+ await settle(batches.add({ signal, body, createdAt: Date.now() } 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))
+ }
+
+ /** Send what is stored, oldest first. Stops at the first failure, keeping the rest. */
+ const drain = async (): Promise => {
+ 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()) {
+ const response = await fetch(`${config.endpoint}/v1/${batch.signal}`, {
+ method: "POST",
+ headers,
+ body: new Uint8Array(batch.body),
+ }).catch(() => undefined)
+ // Offline again, or ingest is down: keep the rest for next time.
+ if (!response || response.status >= 500 || response.status === 429) return
+ }
+ const write = await store("readwrite")
+ if (write) await settle(write.delete(batch.id))
+ }
+ }
+
+ /** The resend in flight: a second call joins it rather than returning before it is done. */
+ let inflight: Promise | undefined
+ const run = async (): Promise => {
+ try {
+ // The store is shared by every tab of the origin: tabs drain it in turn, and a
+ // later one finds what an earlier one sent already deleted.
+ if (typeof navigator !== "undefined" && navigator.locks) {
+ await navigator.locks.request(`${DB_NAME}-resend`, () => drain())
+ } else {
+ await drain()
+ }
+ } catch {
+ // Storage went away mid-resend; the batches stay for the next attempt.
+ }
+ }
+ const resend = (): Promise => {
+ if (!hasConsent() || (typeof navigator !== "undefined" && navigator.onLine === false))
+ return Promise.resolve()
+ inflight ??= run().finally(() => {
+ inflight = undefined
+ })
+ return inflight
+ }
+
+ const clear = async (): Promise => {
+ const batches = await store("readwrite")
+ if (batches) await settle(batches.clear())
+ }
+
+ 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(() => {})
+ })
+ void resend()
+
+ return {
+ stashSpans: (spans) => {
+ if (spans.length > 0)
+ void add("traces", JsonTraceSerializer.serializeRequest(spans)).catch(() => {})
+ },
+ stashLogs: (logs) => {
+ if (logs.length > 0) void add("logs", JsonLogsSerializer.serializeRequest(logs)).catch(() => {})
+ },
+ resend,
+ stop: () => {
+ window.removeEventListener("online", onOnline)
+ stopConsent()
+ void db.then((opened) => opened?.close())
+ },
+ }
+}
diff --git a/packages/browser/src/navigation.test.ts b/packages/browser/src/navigation.test.ts
index fc60abcf8..cf08b6f6c 100644
--- a/packages/browser/src/navigation.test.ts
+++ b/packages/browser/src/navigation.test.ts
@@ -53,6 +53,7 @@ const CONFIG = {
captureConsole: [],
reportCsp: false,
reportBrowser: false,
+ offlineQueue: false,
sanitizeUrl: undefined,
}
diff --git a/packages/browser/src/offline.ts b/packages/browser/src/offline.ts
new file mode 100644
index 000000000..856466233
--- /dev/null
+++ b/packages/browser/src/offline.ts
@@ -0,0 +1,51 @@
+// The eager half of the offline queue: span batches the exporter gave up on
+// (after its own retries) are handed to the deferred chunk, which stores them
+// and sends them again once the browser is back online.
+import type { ReadableSpan, SpanExporter } from "@opentelemetry/sdk-trace-base"
+
+type SpanStash = (spans: ReadableSpan[]) => void
+
+/** Failed batches held until the deferred chunk lands. */
+const MAX_HELD = 20
+
+let stash: SpanStash | undefined
+let held: ReadableSpan[][] = []
+
+export function attachSpanStash(next: SpanStash | undefined): void {
+ stash = next
+ if (!next) return
+ const pending = held
+ held = []
+ for (const spans of pending) next(spans)
+}
+
+export class OfflineSpanExporter implements SpanExporter {
+ constructor(private readonly inner: SpanExporter) {}
+
+ export(spans: ReadableSpan[], callback: (result: { code: number; error?: Error }) => void): void {
+ this.inner.export(spans, (result) => {
+ if (result.code !== 0) {
+ if (stash) stash(spans)
+ else {
+ held.push(spans)
+ if (held.length > MAX_HELD) held.shift()
+ }
+ }
+ callback(result)
+ })
+ }
+
+ forceFlush(): Promise {
+ return this.inner.forceFlush?.() ?? Promise.resolve()
+ }
+
+ shutdown(): Promise {
+ return this.inner.shutdown()
+ }
+}
+
+/** Test seam. */
+export function resetOfflineForTests(): void {
+ stash = undefined
+ held = []
+}
diff --git a/packages/browser/src/tracing.browser.test.ts b/packages/browser/src/tracing.browser.test.ts
index 7d3178d2f..f8b19ac41 100644
--- a/packages/browser/src/tracing.browser.test.ts
+++ b/packages/browser/src/tracing.browser.test.ts
@@ -62,6 +62,7 @@ const CONFIG = {
captureConsole: [],
reportCsp: false,
reportBrowser: false,
+ offlineQueue: false,
sanitizeUrl: undefined,
}
diff --git a/packages/browser/src/tracing.ts b/packages/browser/src/tracing.ts
index b23794ae2..6c8fc8d6e 100644
--- a/packages/browser/src/tracing.ts
+++ b/packages/browser/src/tracing.ts
@@ -26,6 +26,7 @@ import { WebTracerProvider } from "@opentelemetry/sdk-trace-web"
import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"
import type { ResolvedConfig } from "./config"
import { HttpStatusExporter } from "./http-status"
+import { OfflineSpanExporter } from "./offline"
import { SessionSampler } from "./sampling"
import { SDK_NAME, SDK_VERSION } from "./version"
@@ -164,14 +165,15 @@ export function resourceAttributes(config: ResolvedConfig): Record Promise {
+ const otlp = new OTLPTraceExporter({
+ url: `${config.endpoint}/v1/traces`,
+ // The same auth + `x-maple-sdk` headers as every session write; a page
+ // cannot set `user-agent`, so ingest reads the SDK from the latter.
+ headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }),
+ })
const exporter = new ConsentSpanExporter(
new HttpStatusExporter(
- new OTLPTraceExporter({
- url: `${config.endpoint}/v1/traces`,
- // The same auth + `x-maple-sdk` headers as every session write; a page
- // cannot set `user-agent`, so ingest reads the SDK from the latter.
- headers: ingestHeaders({ ingestKey: config.ingestKey, sdk: sdkHint(SDK_NAME, SDK_VERSION) }),
- }),
+ config.offlineQueue ? new OfflineSpanExporter(otlp) : otlp,
config.errorFilters.captureHttpStatus,
),
)