From 338c1b5e70addb092fb76b15fdac044d827fd10f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:35:00 +0200 Subject: [PATCH 1/7] feat(browser): headless user feedback API MapleBrowser.sendFeedback({ message, email?, name?, attributes? }) sends a maple.user_feedback OTel log event: session.id, user.email / user.name (semconv; the email is dropped when privacy.captureUserEmail is false), maple.feedback.has_replay and, when the user hit an error earlier in the page, maple.feedback.error_trace_id. The event is linked to that error's span, so it shows on the error's trace. Feedback also keeps a buffered replay, the same way an error does: a user reporting a problem is as good a signal. Headless for now; a drop-in widget can come as its own entry so it never touches the eager bundle. Eager budget 43 -> 43.5 kB, first-party 17.5 -> 18 kB. --- docs/browser-sdk.md | 20 ++++ packages/browser/README.md | 9 ++ packages/browser/scripts/size.ts | 9 +- packages/browser/src/feedback.browser.test.ts | 97 +++++++++++++++++++ packages/browser/src/feedback.ts | 71 ++++++++++++++ packages/browser/src/index.ts | 10 ++ packages/browser/src/init.ts | 10 +- 7 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 packages/browser/src/feedback.browser.test.ts create mode 100644 packages/browser/src/feedback.ts diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index 2429b849d..98aba872b 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -360,6 +360,26 @@ Each event carries `session.id` and is linked to the page's `pageload` span when `startNavigation`. CLS, INP and LCP settle when the page is hidden, so they arrive then. Turn them off with `webVitals: false`. +## User feedback + +`MapleBrowser.sendFeedback` records what a user tells you, from your own feedback form: + +```ts +MapleBrowser.sendFeedback({ + message: form.message, // required + email: form.email, // optional; dropped when privacy.captureUserEmail is false + name: form.name, // optional + attributes: { "feedback.category": "bug" }, +}) +``` + +It is sent as a `maple.user_feedback` OpenTelemetry log event carrying `session.id`, `user.email` / +`user.name`, `maple.feedback.has_replay` and, when the user hit an error earlier in the page, +`maple.feedback.error_trace_id`. The event is linked to that error's span, so it shows up on the +error's trace. Sending feedback also keeps a buffered replay (see +[Replay on error](#replay-on-error)), since a user reporting a problem is as good a signal as an +error. It returns `false` when nothing was sent (an empty message, or no consent). + ## Browser reports Content Security Policy violations are reported as `maple.browser.csp_violation` WARN log events with diff --git a/packages/browser/README.md b/packages/browser/README.md index c1596c640..e72b51241 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -139,6 +139,15 @@ Calls before `init()` are queued. MapleBrowser.logger.info("checkout started", { "cart.items": 3 }) ``` +## User feedback + +```ts +MapleBrowser.sendFeedback({ message: "The pay button does nothing", email: user.email }) +``` + +Headless (bring your own form). Sent as a `maple.user_feedback` log event linked to the session and +the last error the user hit, and keeps a buffered replay like an error does. + ## Web Vitals LCP, CLS, INP, FCP and TTFB are reported as `browser.web_vital` OpenTelemetry log events diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index 9ed05a7f6..d96ca417d 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: `sendFeedback` (~0.3 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 + * `sendFeedback` API. */ - firstParty: 17.5, + firstParty: 18, } /** How close to a ceiling counts as worth warning about. */ diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts new file mode 100644 index 000000000..1fb4a3328 --- /dev/null +++ b/packages/browser/src/feedback.browser.test.ts @@ -0,0 +1,97 @@ +// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. +import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" +import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +const exportedSpans: ReadableSpan[] = [] +const exportedLogs: ReadableLogRecord[] = [] +const exporter = (sink: T[]) => + class { + export(items: T[], callback: (result: { code: number }) => void): void { + sink.push(...items) + callback({ code: 0 }) + } + forceFlush(): Promise { + return Promise.resolve() + } + shutdown(): Promise { + return Promise.resolve() + } + } +vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ OTLPTraceExporter: exporter(exportedSpans) })) +vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ OTLPLogExporter: exporter(exportedLogs) })) + +const { MapleBrowser } = await import("./index") +const { resetFeedbackForTests } = await import("./feedback") +const { resetReportedErrorsForTests } = await import("./errors") + +type InitConfig = Parameters[0] +const BASE: InitConfig = { + ingestKey: "k", + serviceName: "web", + endpoint: "https://ingest.test", + replay: { enabled: false }, + tracing: { instrumentFetch: false, instrumentXhr: false }, + webVitals: false, + breadcrumbs: false, +} + +let handle: ReturnType | undefined +const stop = async (): Promise => { + await handle?.shutdown() + handle = undefined +} + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("{}")), + ) +}) + +afterEach(async () => { + await stop() + exportedSpans.length = 0 + exportedLogs.length = 0 + resetFeedbackForTests() + resetReportedErrorsForTests() + vi.unstubAllGlobals() +}) + +const feedback = () => exportedLogs.filter((log) => log.eventName === "maple.user_feedback") + +describe("MapleBrowser.sendFeedback", () => { + it("sends a feedback event linked to the last error the user hit", async () => { + handle = MapleBrowser.init(BASE) + MapleBrowser.captureException(new Error("checkout failed")) + expect( + MapleBrowser.sendFeedback({ + message: " the pay button does nothing ", + email: "ada@example.com", + name: "Ada", + attributes: { "feedback.category": "bug" }, + }), + ).toBe(true) + await stop() + + const error = exportedSpans.find((span) => span.name === "exception") + const [sent] = feedback() + expect(sent?.body).toBe("the pay button does nothing") + expect(sent?.attributes["user.email"]).toBe("ada@example.com") + expect(sent?.attributes["user.name"]).toBe("Ada") + expect(sent?.attributes["feedback.category"]).toBe("bug") + expect(sent?.attributes["maple.feedback.error_trace_id"]).toBe(error?.spanContext().traceId) + expect(sent?.spanContext?.spanId).toBe(error?.spanContext().spanId) + expect(typeof sent?.attributes["session.id"]).toBe("string") + }) + + it("keeps the email out when captureUserEmail is off, and sends nothing without a message", async () => { + handle = MapleBrowser.init({ ...BASE, privacy: { captureUserEmail: false } }) + expect(MapleBrowser.sendFeedback({ message: " " })).toBe(false) + MapleBrowser.sendFeedback({ message: "slow", email: "ada@example.com" }) + await stop() + expect(feedback()).toHaveLength(1) + expect(feedback()[0]?.attributes["user.email"]).toBeUndefined() + expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined() + }) +}) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts new file mode 100644 index 000000000..79f0e3752 --- /dev/null +++ b/packages/browser/src/feedback.ts @@ -0,0 +1,71 @@ +// End-user feedback ("this page is broken"), as an OTel log event linked to +// the session and to the last error the user hit. Headless: bring your own UI. +import { getSession, hasConsent } from "@maple/browser-session" +import type { SpanContext } from "@opentelemetry/api" +import { onErrorRecorded } from "./errors" +import { emitLog, type LogAttributeValue, Severity } from "./logs" + +export interface FeedbackInput { + /** What the user wrote. Required; trimmed and capped at 5,000 characters. */ + readonly message: string + /** Sent as `user.email` unless `privacy.captureUserEmail` is false. */ + readonly email?: string | undefined + /** Sent as `user.name`. */ + readonly name?: string | undefined + /** Extra attributes, e.g. `{ "feedback.category": "bug" }`. */ + readonly attributes?: Readonly> | undefined +} + +const MAX_MESSAGE = 5_000 + +let lastError: SpanContext | undefined +let captureEmail = true +/** Keeps a buffered replay; set by `init()`. */ +let keepReplay: () => void = () => {} + +// Module-level: the last error is whatever the user saw most recently, whichever `init()` recorded it. +onErrorRecorded((spanContext) => { + lastError = spanContext +}) + +export function configureFeedback(options: { + readonly captureUserEmail: boolean + readonly keepReplay: () => void +}): void { + captureEmail = options.captureUserEmail + keepReplay = options.keepReplay +} + +export function resetFeedbackForTests(): void { + lastError = undefined + captureEmail = true + keepReplay = () => {} +} + +/** Send feedback. Returns false when there was nothing to send (empty message, no consent). */ +export function sendFeedback(input: FeedbackInput): boolean { + const message = String(input.message ?? "") + .trim() + .slice(0, MAX_MESSAGE) + if (!message || !hasConsent()) return false + const email = captureEmail ? input.email?.trim() : undefined + const name = input.name?.trim() + const recorded = typeof window === "undefined" ? false : getSession().replaySampled === true + emitLog({ + eventName: "maple.user_feedback", + severityNumber: Severity.INFO, + severityText: "INFO", + body: message, + spanContext: lastError, + attributes: { + ...input.attributes, + ...(email ? { "user.email": email } : undefined), + ...(name ? { "user.name": name } : undefined), + ...(lastError ? { "maple.feedback.error_trace_id": lastError.traceId } : undefined), + "maple.feedback.has_replay": recorded, + }, + }) + // Feedback is a user saying something went wrong: worth the buffered minute, like an error. + keepReplay() + return true +} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 582d8e2df..7b3310587 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,6 +2,7 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/b import type { MapleBrowserConfig } from "./config" import { type CaptureExceptionOptions, captureException } from "./errors" import { identify, init, type MapleBrowserHandle } from "./init" +import { type FeedbackInput, sendFeedback } from "./feedback" import { type MapleLogger, logger } from "./logger" import { endNavigation, startNavigation, type TracedOptions, traced } from "./navigation" @@ -19,6 +20,7 @@ export type { HttpStatusRange } from "./http-status" export type { MapleBrowserHandle } from "./init" export type { LogAttributeValue } from "./logs" export type { MapleLogger } from "./logger" +export type { FeedbackInput } from "./feedback" export type { TracedOptions } from "./navigation" /** The `MapleBrowser` namespace object. */ @@ -62,6 +64,13 @@ export interface MapleBrowserApi { * span and the session. Safe before `init`: records queue until it runs. */ logger: MapleLogger + /** + * Send end-user feedback, linked to the session, its replay, and the last + * error the user hit. Headless: call it from your own form. Also keeps a + * buffered replay (`replay.onErrorSampleRate`). Returns false when nothing + * was sent (empty message, no consent). + */ + sendFeedback: (input: FeedbackInput) => boolean } /** @@ -92,4 +101,5 @@ export const MapleBrowser: MapleBrowserApi = { endNavigation, traced, logger, + sendFeedback, } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index 2f0f7285b..a81949627 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -29,6 +29,7 @@ import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" import { configureErrorFilters } from "./error-filters" import { onErrorRecorded, setupErrorCapture } from "./errors" +import { configureFeedback } from "./feedback" import { setLogIdentity } from "./logs" import { resetNavigation } from "./navigation" import { setupTracing } from "./tracing" @@ -214,10 +215,12 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { await Promise.all([replayShutdown, metadataShutdown, previous.replayPending]) } - // A buffered replay keeps itself the moment an error is recorded. - const stopReplayTrigger = onErrorRecorded(() => { + // A buffered replay keeps itself the moment an error is recorded, or the user sends feedback. + const keepReplay = (): void => { void runtime?.replay?.trigger() - }) + } + const stopReplayTrigger = onErrorRecorded(keepReplay) + configureFeedback({ captureUserEmail: config.captureUserEmail, keepReplay }) startRuntime() const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => { @@ -243,6 +246,7 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { stopped = true stopConsentListener() stopReplayTrigger() + configureFeedback({ captureUserEmail: true, keepReplay: () => {} }) await stopRuntime(true) stopErrorCapture?.() stopErrorCapture = undefined From 374a42264bac36e7b263339ef72168775048cfd4 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 20:37:53 +0200 Subject: [PATCH 2/7] feat(browser): opt-in offline queue for spans and logs The OTLP exporters retry a failed export for about 10 seconds, then the batch is gone. transport.offline keeps it instead. - A thin eager wrapper sits right around the OTLP trace exporter (after the consent and HTTP status policies, so what is kept is what would have been sent) and hands failed batches to the deferred chunk, holding up to 20 until it lands. The logs exporter is wrapped the same way. - The deferred chunk serializes them with the OTLP JSON serializers the exporters use and stores them in IndexedDB, then POSTs the raw payloads again on `online` and on the next page load. A 5xx or 429 keeps the rest for later; any other response drops the batch. At most 100 batches, 24h. - Revoking consent clears the queue. No IndexedDB (private windows): nothing is kept. Off by default. otlp-transformer becomes a direct dependency (it was already bundled through the exporters). --- bun.lock | 1 + docs/browser-sdk.md | 10 ++ packages/browser/package.json | 1 + packages/browser/src/config.ts | 10 ++ packages/browser/src/deferred/index.ts | 8 +- packages/browser/src/deferred/logs.ts | 39 ++++- .../src/deferred/offline.browser.test.ts | 119 +++++++++++++++ packages/browser/src/deferred/offline.ts | 136 ++++++++++++++++++ packages/browser/src/navigation.test.ts | 1 + packages/browser/src/offline.ts | 51 +++++++ packages/browser/src/tracing.browser.test.ts | 1 + packages/browser/src/tracing.ts | 14 +- 12 files changed, 377 insertions(+), 14 deletions(-) create mode 100644 packages/browser/src/deferred/offline.browser.test.ts create mode 100644 packages/browser/src/deferred/offline.ts create mode 100644 packages/browser/src/offline.ts 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 98aba872b..de4877269 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. | @@ -514,6 +515,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/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..bb6e60544 --- /dev/null +++ b/packages/browser/src/deferred/offline.browser.test.ts @@ -0,0 +1,119 @@ +import { resetConsentForTests } 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("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..b7ac9f82e --- /dev/null +++ b/packages/browser/src/deferred/offline.ts @@ -0,0 +1,136 @@ +// 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 { 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)) + } + + let resending = false + const resend = async (): Promise => { + if (resending || !hasConsent() || (typeof navigator !== "undefined" && navigator.onLine === false)) + return + resending = true + try { + const read = await store("readonly") + const stored = read ? (await settle(read.getAll())).filter(isStoredBatch) : [] + for (const batch of stored) { + if (Date.now() - batch.createdAt <= MAX_AGE_MS) { + 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)) + } + } catch { + // Storage went away mid-resend; the batches stay for the next attempt. + } finally { + resending = false + } + } + + 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, ), ) From 1af65dedb27d5df0f8dd0b6bc81df8f7cd058060 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:15:12 +0200 Subject: [PATCH 3/7] fix(browser): has_replay reflects the buffered replay feedback keeps maple.feedback.has_replay was read before keepReplay() ran, so on the buffered-replay path it was always false for the very feedback that kept the replay. keepReplay() now runs first; the trigger marks the session recorded synchronously. --- packages/browser/src/feedback.browser.test.ts | 28 +++++++++++++++++++ packages/browser/src/feedback.ts | 5 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts index 1fb4a3328..3cb99bd3b 100644 --- a/packages/browser/src/feedback.browser.test.ts +++ b/packages/browser/src/feedback.browser.test.ts @@ -94,4 +94,32 @@ describe("MapleBrowser.sendFeedback", () => { expect(feedback()[0]?.attributes["user.email"]).toBeUndefined() expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined() }) + + it("reports has_replay for the buffered replay the feedback itself keeps", async () => { + // Headless Chromium's user agent is classified as a bot, which never gets replay. + Object.defineProperty(navigator, "userAgent", { + value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", + configurable: true, + }) + const urls: string[] = [] + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + urls.push(String(input)) + return new Response("{}") + }), + ) + try { + handle = MapleBrowser.init({ ...BASE, replay: { sampleRate: 0, onErrorSampleRate: 1 } }) + await vi.waitFor(() => + expect(urls.some((url) => url.endsWith("/v1/sessionReplays/meta"))).toBe(true), + ) + MapleBrowser.sendFeedback({ message: "it froze" }) + await stop() + expect(feedback()[0]?.attributes["maple.feedback.has_replay"]).toBe(true) + } finally { + Reflect.deleteProperty(navigator, "userAgent") + sessionStorage.clear() + } + }) }) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts index 79f0e3752..fe53277d4 100644 --- a/packages/browser/src/feedback.ts +++ b/packages/browser/src/feedback.ts @@ -50,6 +50,9 @@ export function sendFeedback(input: FeedbackInput): boolean { if (!message || !hasConsent()) return false const email = captureEmail ? input.email?.trim() : undefined const name = input.name?.trim() + // Keep a buffered replay first: the trigger marks the session recorded synchronously, so + // `has_replay` then reflects the minute this feedback just kept. + keepReplay() const recorded = typeof window === "undefined" ? false : getSession().replaySampled === true emitLog({ eventName: "maple.user_feedback", @@ -65,7 +68,5 @@ export function sendFeedback(input: FeedbackInput): boolean { "maple.feedback.has_replay": recorded, }, }) - // Feedback is a user saying something went wrong: worth the buffered minute, like an error. - keepReplay() return true } From d7cbd7b2062b10c80b5b4a96b4f49a21e8dbf36f Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:15:58 +0200 Subject: [PATCH 4/7] fix(browser): never resend offline batches from before the consent grant A revoke the live queue never saw (it landed after stop(), or on another page) left batches in IndexedDB, and the next page load sent them under its fresh grant. resend now drops batches older than consentAllowedSince(), the rule the consent exporters already apply per record. --- .../src/deferred/offline.browser.test.ts | 36 ++++++++++++++++++- packages/browser/src/deferred/offline.ts | 11 ++++-- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/browser/src/deferred/offline.browser.test.ts index bb6e60544..5886bcffb 100644 --- a/packages/browser/src/deferred/offline.browser.test.ts +++ b/packages/browser/src/deferred/offline.browser.test.ts @@ -1,4 +1,4 @@ -import { resetConsentForTests } from "@maple/browser-session" +import { configurePrivacy, resetConsentForTests, setConsent } from "@maple/browser-session" import { BasicTracerProvider, InMemorySpanExporter, @@ -101,6 +101,40 @@ describe("offline queue", () => { }) }) +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) + 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 = { diff --git a/packages/browser/src/deferred/offline.ts b/packages/browser/src/deferred/offline.ts index b7ac9f82e..c195983ef 100644 --- a/packages/browser/src/deferred/offline.ts +++ b/packages/browser/src/deferred/offline.ts @@ -2,7 +2,13 @@ // 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 { hasConsent, ingestHeaders, onConsentChange, sdkHint } from "@maple/browser-session" +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" @@ -86,7 +92,8 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { const read = await store("readonly") const stored = read ? (await settle(read.getAll())).filter(isStoredBatch) : [] for (const batch of stored) { - if (Date.now() - batch.createdAt <= MAX_AGE_MS) { + // 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, From 04e5138e233f97f508094ba3e70fa149b6471e0b Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 22:18:16 +0200 Subject: [PATCH 5/7] fix(browser): forget the last error when the SDK restarts lastError survived shutdown(), so feedback after a re-init linked to a trace from the previous lifecycle. configureFeedback, run by init() and shutdown(), now clears it. --- packages/browser/src/feedback.browser.test.ts | 10 ++++++++++ packages/browser/src/feedback.ts | 2 ++ 2 files changed, 12 insertions(+) diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts index 3cb99bd3b..f9f0fcb6f 100644 --- a/packages/browser/src/feedback.browser.test.ts +++ b/packages/browser/src/feedback.browser.test.ts @@ -85,6 +85,16 @@ describe("MapleBrowser.sendFeedback", () => { expect(typeof sent?.attributes["session.id"]).toBe("string") }) + it("does not link feedback to an error from before a shutdown and re-init", async () => { + handle = MapleBrowser.init(BASE) + MapleBrowser.captureException(new Error("old page state")) + await stop() + handle = MapleBrowser.init(BASE) + MapleBrowser.sendFeedback({ message: "still broken" }) + await stop() + expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined() + }) + it("keeps the email out when captureUserEmail is off, and sends nothing without a message", async () => { handle = MapleBrowser.init({ ...BASE, privacy: { captureUserEmail: false } }) expect(MapleBrowser.sendFeedback({ message: " " })).toBe(false) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts index fe53277d4..408021652 100644 --- a/packages/browser/src/feedback.ts +++ b/packages/browser/src/feedback.ts @@ -34,6 +34,8 @@ export function configureFeedback(options: { }): void { captureEmail = options.captureUserEmail keepReplay = options.keepReplay + // init() and shutdown() both start a new lifecycle: an earlier error's trace is not this one's. + lastError = undefined } export function resetFeedbackForTests(): void { From c2c1960e8b274e003d1ff0d35c924182f7fb6e3d Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 23:10:52 +0200 Subject: [PATCH 6/7] revert(browser): drop the headless feedback API sendFeedback was a thin add-on with nowhere in the product to read it. End-user feedback should come back as a proper feature built on product events, not as a log event with no surface. Size budgets are re-attributed to the offline queue's exporter wrapper, which used the headroom. --- docs/browser-sdk.md | 20 --- packages/browser/README.md | 9 -- packages/browser/scripts/size.ts | 4 +- packages/browser/src/feedback.browser.test.ts | 135 ------------------ packages/browser/src/feedback.ts | 74 ---------- packages/browser/src/index.ts | 10 -- packages/browser/src/init.ts | 10 +- 7 files changed, 5 insertions(+), 257 deletions(-) delete mode 100644 packages/browser/src/feedback.browser.test.ts delete mode 100644 packages/browser/src/feedback.ts diff --git a/docs/browser-sdk.md b/docs/browser-sdk.md index de4877269..7fe600c3a 100644 --- a/docs/browser-sdk.md +++ b/docs/browser-sdk.md @@ -361,26 +361,6 @@ Each event carries `session.id` and is linked to the page's `pageload` span when `startNavigation`. CLS, INP and LCP settle when the page is hidden, so they arrive then. Turn them off with `webVitals: false`. -## User feedback - -`MapleBrowser.sendFeedback` records what a user tells you, from your own feedback form: - -```ts -MapleBrowser.sendFeedback({ - message: form.message, // required - email: form.email, // optional; dropped when privacy.captureUserEmail is false - name: form.name, // optional - attributes: { "feedback.category": "bug" }, -}) -``` - -It is sent as a `maple.user_feedback` OpenTelemetry log event carrying `session.id`, `user.email` / -`user.name`, `maple.feedback.has_replay` and, when the user hit an error earlier in the page, -`maple.feedback.error_trace_id`. The event is linked to that error's span, so it shows up on the -error's trace. Sending feedback also keeps a buffered replay (see -[Replay on error](#replay-on-error)), since a user reporting a problem is as good a signal as an -error. It returns `false` when nothing was sent (an empty message, or no consent). - ## Browser reports Content Security Policy violations are reported as `maple.browser.csp_violation` WARN log events with diff --git a/packages/browser/README.md b/packages/browser/README.md index e72b51241..c1596c640 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -139,15 +139,6 @@ Calls before `init()` are queued. MapleBrowser.logger.info("checkout started", { "cart.items": 3 }) ``` -## User feedback - -```ts -MapleBrowser.sendFeedback({ message: "The pay button does nothing", email: user.email }) -``` - -Headless (bring your own form). Sent as a `maple.user_feedback` log event linked to the session and -the last error the user hit, and keeps a buffered replay like an error does. - ## Web Vitals LCP, CLS, INP, FCP and TTFB are reported as `browser.web_vital` OpenTelemetry log events diff --git a/packages/browser/scripts/size.ts b/packages/browser/scripts/size.ts index d96ca417d..cbac0a0cb 100644 --- a/packages/browser/scripts/size.ts +++ b/packages/browser/scripts/size.ts @@ -24,7 +24,7 @@ import { gzipSync } from "node:zlib" /** Ceilings in gzipped KB. Raise deliberately, with the reason in the commit. */ const BUDGET = { /** - * 43.5 since 2026-09: `sendFeedback` (~0.3 kB). 43: 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 @@ -64,7 +64,7 @@ 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 - * `sendFeedback` API. + * offline queue's exporter wrapper. */ firstParty: 18, } diff --git a/packages/browser/src/feedback.browser.test.ts b/packages/browser/src/feedback.browser.test.ts deleted file mode 100644 index f9f0fcb6f..000000000 --- a/packages/browser/src/feedback.browser.test.ts +++ /dev/null @@ -1,135 +0,0 @@ -// TEST-SEAM: This focused test replaces process-global modules that have no instance-level injection seam. -import type { ReadableLogRecord } from "@opentelemetry/sdk-logs" -import type { ReadableSpan } from "@opentelemetry/sdk-trace-base" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" - -const exportedSpans: ReadableSpan[] = [] -const exportedLogs: ReadableLogRecord[] = [] -const exporter = (sink: T[]) => - class { - export(items: T[], callback: (result: { code: number }) => void): void { - sink.push(...items) - callback({ code: 0 }) - } - forceFlush(): Promise { - return Promise.resolve() - } - shutdown(): Promise { - return Promise.resolve() - } - } -vi.mock("@opentelemetry/exporter-trace-otlp-http", () => ({ OTLPTraceExporter: exporter(exportedSpans) })) -vi.mock("@opentelemetry/exporter-logs-otlp-http", () => ({ OTLPLogExporter: exporter(exportedLogs) })) - -const { MapleBrowser } = await import("./index") -const { resetFeedbackForTests } = await import("./feedback") -const { resetReportedErrorsForTests } = await import("./errors") - -type InitConfig = Parameters[0] -const BASE: InitConfig = { - ingestKey: "k", - serviceName: "web", - endpoint: "https://ingest.test", - replay: { enabled: false }, - tracing: { instrumentFetch: false, instrumentXhr: false }, - webVitals: false, - breadcrumbs: false, -} - -let handle: ReturnType | undefined -const stop = async (): Promise => { - await handle?.shutdown() - handle = undefined -} - -beforeEach(() => { - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response("{}")), - ) -}) - -afterEach(async () => { - await stop() - exportedSpans.length = 0 - exportedLogs.length = 0 - resetFeedbackForTests() - resetReportedErrorsForTests() - vi.unstubAllGlobals() -}) - -const feedback = () => exportedLogs.filter((log) => log.eventName === "maple.user_feedback") - -describe("MapleBrowser.sendFeedback", () => { - it("sends a feedback event linked to the last error the user hit", async () => { - handle = MapleBrowser.init(BASE) - MapleBrowser.captureException(new Error("checkout failed")) - expect( - MapleBrowser.sendFeedback({ - message: " the pay button does nothing ", - email: "ada@example.com", - name: "Ada", - attributes: { "feedback.category": "bug" }, - }), - ).toBe(true) - await stop() - - const error = exportedSpans.find((span) => span.name === "exception") - const [sent] = feedback() - expect(sent?.body).toBe("the pay button does nothing") - expect(sent?.attributes["user.email"]).toBe("ada@example.com") - expect(sent?.attributes["user.name"]).toBe("Ada") - expect(sent?.attributes["feedback.category"]).toBe("bug") - expect(sent?.attributes["maple.feedback.error_trace_id"]).toBe(error?.spanContext().traceId) - expect(sent?.spanContext?.spanId).toBe(error?.spanContext().spanId) - expect(typeof sent?.attributes["session.id"]).toBe("string") - }) - - it("does not link feedback to an error from before a shutdown and re-init", async () => { - handle = MapleBrowser.init(BASE) - MapleBrowser.captureException(new Error("old page state")) - await stop() - handle = MapleBrowser.init(BASE) - MapleBrowser.sendFeedback({ message: "still broken" }) - await stop() - expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined() - }) - - it("keeps the email out when captureUserEmail is off, and sends nothing without a message", async () => { - handle = MapleBrowser.init({ ...BASE, privacy: { captureUserEmail: false } }) - expect(MapleBrowser.sendFeedback({ message: " " })).toBe(false) - MapleBrowser.sendFeedback({ message: "slow", email: "ada@example.com" }) - await stop() - expect(feedback()).toHaveLength(1) - expect(feedback()[0]?.attributes["user.email"]).toBeUndefined() - expect(feedback()[0]?.attributes["maple.feedback.error_trace_id"]).toBeUndefined() - }) - - it("reports has_replay for the buffered replay the feedback itself keeps", async () => { - // Headless Chromium's user agent is classified as a bot, which never gets replay. - Object.defineProperty(navigator, "userAgent", { - value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36", - configurable: true, - }) - const urls: string[] = [] - vi.stubGlobal( - "fetch", - vi.fn(async (input: RequestInfo | URL) => { - urls.push(String(input)) - return new Response("{}") - }), - ) - try { - handle = MapleBrowser.init({ ...BASE, replay: { sampleRate: 0, onErrorSampleRate: 1 } }) - await vi.waitFor(() => - expect(urls.some((url) => url.endsWith("/v1/sessionReplays/meta"))).toBe(true), - ) - MapleBrowser.sendFeedback({ message: "it froze" }) - await stop() - expect(feedback()[0]?.attributes["maple.feedback.has_replay"]).toBe(true) - } finally { - Reflect.deleteProperty(navigator, "userAgent") - sessionStorage.clear() - } - }) -}) diff --git a/packages/browser/src/feedback.ts b/packages/browser/src/feedback.ts deleted file mode 100644 index 408021652..000000000 --- a/packages/browser/src/feedback.ts +++ /dev/null @@ -1,74 +0,0 @@ -// End-user feedback ("this page is broken"), as an OTel log event linked to -// the session and to the last error the user hit. Headless: bring your own UI. -import { getSession, hasConsent } from "@maple/browser-session" -import type { SpanContext } from "@opentelemetry/api" -import { onErrorRecorded } from "./errors" -import { emitLog, type LogAttributeValue, Severity } from "./logs" - -export interface FeedbackInput { - /** What the user wrote. Required; trimmed and capped at 5,000 characters. */ - readonly message: string - /** Sent as `user.email` unless `privacy.captureUserEmail` is false. */ - readonly email?: string | undefined - /** Sent as `user.name`. */ - readonly name?: string | undefined - /** Extra attributes, e.g. `{ "feedback.category": "bug" }`. */ - readonly attributes?: Readonly> | undefined -} - -const MAX_MESSAGE = 5_000 - -let lastError: SpanContext | undefined -let captureEmail = true -/** Keeps a buffered replay; set by `init()`. */ -let keepReplay: () => void = () => {} - -// Module-level: the last error is whatever the user saw most recently, whichever `init()` recorded it. -onErrorRecorded((spanContext) => { - lastError = spanContext -}) - -export function configureFeedback(options: { - readonly captureUserEmail: boolean - readonly keepReplay: () => void -}): void { - captureEmail = options.captureUserEmail - keepReplay = options.keepReplay - // init() and shutdown() both start a new lifecycle: an earlier error's trace is not this one's. - lastError = undefined -} - -export function resetFeedbackForTests(): void { - lastError = undefined - captureEmail = true - keepReplay = () => {} -} - -/** Send feedback. Returns false when there was nothing to send (empty message, no consent). */ -export function sendFeedback(input: FeedbackInput): boolean { - const message = String(input.message ?? "") - .trim() - .slice(0, MAX_MESSAGE) - if (!message || !hasConsent()) return false - const email = captureEmail ? input.email?.trim() : undefined - const name = input.name?.trim() - // Keep a buffered replay first: the trigger marks the session recorded synchronously, so - // `has_replay` then reflects the minute this feedback just kept. - keepReplay() - const recorded = typeof window === "undefined" ? false : getSession().replaySampled === true - emitLog({ - eventName: "maple.user_feedback", - severityNumber: Severity.INFO, - severityText: "INFO", - body: message, - spanContext: lastError, - attributes: { - ...input.attributes, - ...(email ? { "user.email": email } : undefined), - ...(name ? { "user.name": name } : undefined), - ...(lastError ? { "maple.feedback.error_trace_id": lastError.traceId } : undefined), - "maple.feedback.has_replay": recorded, - }, - }) - return true -} diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 7b3310587..582d8e2df 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -2,7 +2,6 @@ import { type IdentifyInput, setConsent, type TrackProps, track } from "@maple/b import type { MapleBrowserConfig } from "./config" import { type CaptureExceptionOptions, captureException } from "./errors" import { identify, init, type MapleBrowserHandle } from "./init" -import { type FeedbackInput, sendFeedback } from "./feedback" import { type MapleLogger, logger } from "./logger" import { endNavigation, startNavigation, type TracedOptions, traced } from "./navigation" @@ -20,7 +19,6 @@ export type { HttpStatusRange } from "./http-status" export type { MapleBrowserHandle } from "./init" export type { LogAttributeValue } from "./logs" export type { MapleLogger } from "./logger" -export type { FeedbackInput } from "./feedback" export type { TracedOptions } from "./navigation" /** The `MapleBrowser` namespace object. */ @@ -64,13 +62,6 @@ export interface MapleBrowserApi { * span and the session. Safe before `init`: records queue until it runs. */ logger: MapleLogger - /** - * Send end-user feedback, linked to the session, its replay, and the last - * error the user hit. Headless: call it from your own form. Also keeps a - * buffered replay (`replay.onErrorSampleRate`). Returns false when nothing - * was sent (empty message, no consent). - */ - sendFeedback: (input: FeedbackInput) => boolean } /** @@ -101,5 +92,4 @@ export const MapleBrowser: MapleBrowserApi = { endNavigation, traced, logger, - sendFeedback, } diff --git a/packages/browser/src/init.ts b/packages/browser/src/init.ts index a81949627..2f0f7285b 100644 --- a/packages/browser/src/init.ts +++ b/packages/browser/src/init.ts @@ -29,7 +29,6 @@ import { trace } from "@opentelemetry/api" import { type MapleBrowserConfig, type ResolvedConfig, resolveConfig } from "./config" import { configureErrorFilters } from "./error-filters" import { onErrorRecorded, setupErrorCapture } from "./errors" -import { configureFeedback } from "./feedback" import { setLogIdentity } from "./logs" import { resetNavigation } from "./navigation" import { setupTracing } from "./tracing" @@ -215,12 +214,10 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { await Promise.all([replayShutdown, metadataShutdown, previous.replayPending]) } - // A buffered replay keeps itself the moment an error is recorded, or the user sends feedback. - const keepReplay = (): void => { + // A buffered replay keeps itself the moment an error is recorded. + const stopReplayTrigger = onErrorRecorded(() => { void runtime?.replay?.trigger() - } - const stopReplayTrigger = onErrorRecorded(keepReplay) - configureFeedback({ captureUserEmail: config.captureUserEmail, keepReplay }) + }) startRuntime() const stopConsentListener = config.requireConsent ? onConsentChange((allowed) => { @@ -246,7 +243,6 @@ export function init(rawConfig: MapleBrowserConfig): MapleBrowserHandle { stopped = true stopConsentListener() stopReplayTrigger() - configureFeedback({ captureUserEmail: true, keepReplay: () => {} }) await stopRuntime(true) stopErrorCapture?.() stopErrorCapture = undefined From e5c997c5c184718629af6793455da5c959983752 Mon Sep 17 00:00:00 2001 From: Makisuo Date: Tue, 29 Sep 2026 23:26:34 +0200 Subject: [PATCH 7/7] fix(browser): one tab at a time resends the offline queue IndexedDB is shared by every tab of an origin, so two tabs resending on `online` or page load both read and POSTed the same batches. Resends now run under a Web Locks lease, so tabs take turns and a later one finds the sent batches already deleted. A resend called while one is in flight now joins it instead of returning before the work is done. --- .../src/deferred/offline.browser.test.ts | 29 +++++++++ packages/browser/src/deferred/offline.ts | 59 ++++++++++++------- 2 files changed, 66 insertions(+), 22 deletions(-) diff --git a/packages/browser/src/deferred/offline.browser.test.ts b/packages/browser/src/deferred/offline.browser.test.ts index 5886bcffb..d0bd74d38 100644 --- a/packages/browser/src/deferred/offline.browser.test.ts +++ b/packages/browser/src/deferred/offline.browser.test.ts @@ -101,6 +101,33 @@ describe("offline queue", () => { }) }) +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[] = [] @@ -112,6 +139,8 @@ describe("offline queue and consent", () => { }), ) 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() diff --git a/packages/browser/src/deferred/offline.ts b/packages/browser/src/deferred/offline.ts index c195983ef..4bca3e99b 100644 --- a/packages/browser/src/deferred/offline.ts +++ b/packages/browser/src/deferred/offline.ts @@ -83,34 +83,49 @@ export function startOfflineQueue(config: ResolvedConfig): OfflineQueue { await settle(batches.delete(key)) } - let resending = false - const resend = async (): Promise => { - if (resending || !hasConsent() || (typeof navigator !== "undefined" && navigator.onLine === false)) - return - resending = true + /** 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 { - 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 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. - } finally { - resending = false } } + 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")