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, ), )