Skip to content

feat(browser): opt-in offline queue for spans and logs - #1156

Merged
Makisuo merged 11 commits into
feat/browser-sdk-error-replayfrom
feat/browser-sdk-offline-queue
Sep 29, 2026
Merged

Makisuo merged 11 commits into
feat/browser-sdk-error-replayfrom
feat/browser-sdk-offline-queue

Conversation

@Makisuo

@Makisuo Makisuo commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Part 10 of the browser SDK stack. Based on #1154 (the feedback PR #1155 was dropped; this PR removes its code again, so the net diff against #1154 is the offline queue only).

The OTLP exporters retry a failed export for about 10 seconds (retrying-transport, 5 attempts within the export timeout), then the batch is gone. transport.offline keeps it instead.

What changes

  • A thin eager wrapper sits directly around the OTLP trace exporter, inside the consent and HTTP-status policies, so what is kept is exactly what would have been sent. It hands failed batches to the deferred chunk, holding up to 20 until the chunk lands. The logs exporter is wrapped the same way.
  • The deferred chunk serializes batches with the OTLP JSON serializers the exporters use (@opentelemetry/otlp-transformer, now a direct dependency; it was already bundled) and stores them in IndexedDB.
  • It 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, for up to 24h.
  • Revoking consent clears the queue. With no IndexedDB (some private windows), nothing is kept.
  • Off by default.

Testing

Browser tests against real IndexedDB:

  • a batch stored while fetch fails, then resent as OTLP JSON to /v1/traces and removed once back online;
  • 503 keeps the batch, 400 drops it;
  • empty batches are ignored;
  • the eager wrapper holds failures until the stash is attached.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

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.
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).
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 55 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: a709bd6f-2c04-4e37-a83e-a5c7db003b89

📥 Commits

Reviewing files that changed from the base of the PR and between 648a5ca and e5c997c.

⛔ Files ignored due to path filters (1)
  • bun.lock is excluded by !**/*.lock
📒 Files selected for processing (12)
  • docs/browser-sdk.md
  • packages/browser/package.json
  • packages/browser/scripts/size.ts
  • packages/browser/src/config.ts
  • packages/browser/src/deferred/index.ts
  • packages/browser/src/deferred/logs.ts
  • packages/browser/src/deferred/offline.browser.test.ts
  • packages/browser/src/deferred/offline.ts
  • packages/browser/src/navigation.test.ts
  • packages/browser/src/offline.ts
  • packages/browser/src/tracing.browser.test.ts
  • packages/browser/src/tracing.ts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 2/5 · risky as written
Consent scoping of the stored batches is the one place that needs a careful look; the rest is contained, opt-in and covered by browser tests.
quality 90/100 · 1 warning · tests partial · risk medium · 1/2 new units observable

Adds an opt-in (transport.offline) IndexedDB queue that keeps span and log batches the OTLP exporters gave up on and re-POSTs them on online and on the next page load. Contained and well-tested apart from its consent window, which lets batches from a previous grant be re-uploaded.

  • OfflineSpanExporter/OfflineLogExporter stash failed batches, holding 20 spans until the deferred chunk lands
  • startOfflineQueue stores OTLP JSON bodies in IndexedDB (100 batches, 24h) and resends them
  • transport.offline resolves to offlineQueue, off by default
  • @opentelemetry/otlp-transformer becomes a direct dependency

Findings

Warning · F1 · resend re-uploads batches captured before the current consent grant

correctness · packages/browser/src/deferred/offline.ts:89-96

resend only checks hasConsent() and MAX_AGE_MS, so it sends batches whose createdAt predates the current grant. Consent captures are cleared on onConsentChange, but that only runs while the queue is live: a revoke that lands after stop() (or a queue closed when consent flipped) leaves the rows in IndexedDB, and the next page load — where consentAllowedSince() is the moment of the new grant — posts them. That is the case consent.ts documents as "discard anything buffered before a late grant or across a revoke/re-grant cycle", and which ConsentSpanExporter/ConsentLogExporter already enforce per record (packages/browser/src/tracing.ts:16).

Treat a batch older than the current grant like an expired one: add `consentAllowedSince` to the `@maple/browser-session` import and inside the loop require `batch.createdAt >= consentAllowedSince()` before sending, deleting the batch otherwise.
What was checked
  • Batch cap and expiry trim oldest-first via getAllKeys (deferred/offline.ts:75, :76)
  • Resend re-checks hasConsent() and keeps batches only on 5xx/429 (deferred/offline.ts:96)
  • OWASP-ish refs: ingestHeaders/sdkHint reuse the same auth + x-maple-sdk headers as the exporters (deferred/logs.ts:72)
Observability coverage: 1 of 2 changes observable
Change Kind Observable Evidence
resend POST to /v1/traces|logs from IndexedDB outbound no FetchInstrumentation ignores ${config.endpoint}/v1/ (packages/browser/src/tracing.ts:240), same as the exporters' own ingest calls, so no span is expected
OfflineSpanExporter / OfflineLogExporter wrapper span/log export path yes keeps the HttpStatus/Consent exporter chain and the existing BatchSpanProcessor, no new span surface
Copy all findings (1)
Findings from an automated review of commit 374a42264bac36e7b263339ef72168775048cfd4. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F1 · Warning · correctness · packages/browser/src/deferred/offline.ts:89-96
`resend` re-uploads batches captured before the current consent grant
`resend` only checks `hasConsent()` and `MAX_AGE_MS`, so it sends batches whose `createdAt` predates the current grant. Consent captures are cleared on `onConsentChange`, but that only runs while the queue is live: a revoke that lands after `stop()` (or a queue closed when consent flipped) leaves the rows in IndexedDB, and the next page load — where `consentAllowedSince()` is the moment of the new grant — posts them. That is the case `consent.ts` documents as "discard anything buffered before a late grant or across a revoke/re-grant cycle", and which `ConsentSpanExporter`/`ConsentLogExporter` already enforce per record (`packages/browser/src/tracing.ts:16`).
Suggested fix: Treat a batch older than the current grant like an expired one: add `consentAllowedSince` to the `@maple/browser-session` import and inside the loop require `batch.createdAt >= consentAllowedSince()` before sending, deleting the batch otherwise.

374a422 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread packages/browser/src/deferred/offline.ts Outdated
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.
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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Note

A newer push replaced d7cbd7b before its review finished. The latest commit is reviewed in a new comment.

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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The consent, expiry and batch-cap logic reads correctly; the one risk is duplicate ingest when two tabs drain the same origin-wide IndexedDB store.
quality 90/100 · 1 warning · tests covered · risk medium

Adds an opt-in, IndexedDB-backed offline queue: span and log batches the exporters gave up on are stored as OTLP JSON and POSTed again on online or the next page load, gated by consent, a 24h expiry and a 100-batch cap. Safe to merge once the cross-tab resend is serialised.

  • OfflineSpanExporter in src/offline.ts stashes failed span batches, holding up to 20 until the deferred chunk attaches attachSpanStash
  • startOfflineQueue stores batches in IndexedDB and re-POSTs /v1/traces and /v1/logs on online and at startup
  • startLogs wraps its exporter in OfflineLogExporter; revocation clears the store via onConsentChange
  • config.transport.offline resolves to ResolvedConfig.offlineQueue, default false

Findings

Warning · F2 · resend has no cross-tab guard, so two tabs send every stored batch

correctness · packages/browser/src/deferred/offline.ts:94-96

resend reads the whole maple-offline store (read.getAll()) and POSTs every batch it finds, but IndexedDB is shared per origin: with the queue enabled in two tabs of the same site, both tabs read the same records on online or on page load and both POST them, so ingest sees the spans and logs twice. Nothing serialises the two tabs — the delete at line 106 only affects the other tab's next resend.

Take a cross-tab lease around the send loop, e.g. `navigator.locks.request("maple-offline-resend", async () => { ... })` (skipping when the locks API is absent), so only one tab drains the store at a time.
What was checked
  • resend drops batches whose createdAt predates consentAllowedSince() (consent.ts: Infinity when denied), so the earlier pre-grant resend issue stays fixed
  • add needs hasConsent() and skips empty payloads, so JsonTraceSerializer.serializeRequest never stores an undefined body
  • 5xx/429 and a thrown fetch keep the batch; other statuses delete it, matching the described retry policy
Copy all findings (1)
Findings from an automated review of commit 48cafb97da6fbc0a78a425b6085d236ec23f310e. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F2 · Warning · correctness · packages/browser/src/deferred/offline.ts:94-96
`resend` has no cross-tab guard, so two tabs send every stored batch
`resend` reads the whole `maple-offline` store (`read.getAll()`) and POSTs every batch it finds, but IndexedDB is shared per origin: with the queue enabled in two tabs of the same site, both tabs read the same records on `online` or on page load and both POST them, so ingest sees the spans and logs twice. Nothing serialises the two tabs — the delete at line 106 only affects the other tab's next resend.
Suggested fix: Take a cross-tab lease around the send loop, e.g. `navigator.locks.request("maple-offline-resend", async () => { ... })` (skipping when the locks API is absent), so only one tab drains the store at a time.

48cafb9 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

Comment thread packages/browser/src/deferred/offline.ts Outdated
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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The reverted files leave no dangling reference (sendFeedback/configureFeedback grep at head is empty), but the earlier cross-tab resend finding still stands unfixed.
quality 90/100 · 1 warning · tests covered · risk medium · 1/1 new units observable

The head commit drops the headless sendFeedback API (source, tests, docs and README) that was added on the base branch; the removal is complete and safe. The offline queue itself is unchanged since the last review, and the cross-tab duplication finding on resend is still open.

  • MapleBrowser.sendFeedback/FeedbackInput removed from index.ts, init.ts and its tests
  • configureFeedback wiring and the feedback replay trigger dropped from init.ts
  • transport.offline opt-in queue stashes failed batches via OfflineSpanExporter/OfflineLogExporter
  • startOfflineQueue stores OTLP JSON in IndexedDB and resends on online

Still open from earlier reviews

What was checked
  • No dangling reference to sendFeedback, configureFeedback, FeedbackInput or maple.user_feedback (sandbox_grep repo-wide at head)
  • captureUserEmail still flows config → init.ts:153 → session metadata after the removal
  • resend's consent gate and consentAllowedSince() check at deferred/offline.ts:88,96 still hold after F1's fix
Observability coverage: 1 of 1 changes observable
Change Kind Observable Evidence
offline resend POST to /v1/traces|/v1/logs outbound HTTP yes the payload is the SDK's own OTLP telemetry; a browser SDK does not wrap its exporter in a client span

c2c1960 · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@Makisuo
Makisuo changed the base branch from feat/browser-sdk-feedback to feat/browser-sdk-error-replay September 29, 2026 21:11
@Makisuo
Makisuo added this pull request to stack #1161 September 29, 2026 21:22
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Note

A newer push replaced beb2b7b before its review finished. The latest commit is reviewed in a new comment.

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.
@maple-review-bot

maple-review-bot Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Maple review

Confidence 3/5 · needs attention
The opt-in queue is well tested against real IndexedDB; the one defect is the origin-wide lock held across an untimed POST in drain.
quality 90/100 · 1 warning · tests covered · risk medium

Adds an opt-in (transport.offline, default off) offline queue: span and log batches the exports gave up on are serialized as OTLP JSON into IndexedDB and re-POSTed on online or the next page load, with consent and age limits. The design is sound and the store/resend/keep/drop paths are exercised by browser tests; one liveness defect in the resend lease is worth fixing before merge.

  • resolveConfig maps transport.offline to ResolvedConfig.offlineQueue, default false
  • OfflineSpanExporter/OfflineLogExporter hand failed batches to the deferred queue
  • startOfflineQueue stores OTLP JSON batches in IndexedDB and resends them under a navigator.locks lease
  • Consent revoke clears the stored queue; batches older than 24h or 100 batches are dropped

Findings

Warning · F3 · The resend lease is held across POSTs that have no timeout

correctness · packages/browser/src/deferred/offline.ts:112-116

run takes the origin-wide navigator.locks lease and holds it for the whole drain, whose fetch (line 93) has no timeout. One tab with a stalled connection (the flaky-network case this feature exists for) keeps the lease, so no other tab of the origin can resend, and every online event in that tab joins the same inflight drain (line 124) instead of starting a new one — the stored batches then wait for the next page load. Abort the POST after a short budget (AbortSignal.timeout), which the existing .catch(() => undefined) already turns into "keep it for next time".

Add `signal: AbortSignal.timeout(10_000)` to the drain `fetch`, or scope the lock to the store read/delete and not to the network call.
What was checked
  • tracing.ts:174 wraps inside the consent and HTTP-status exporters, so the stored batch is the one that would have been sent
  • Stored records are validated before resend (isStoredBatch), so batch.signal cannot escape into the /v1/<signal> URL
  • Resend drops pre-consent and expired batches and prunes past MAX_BATCHES (offline.ts:82,92), matching the documented limits
Copy all findings (1)
Findings from an automated review of commit e5c997c5c184718629af6793455da5c959983752. Verify each one against the current code before changing anything, fix only those that still apply, and keep each fix to the lines it names.

---

F3 · Warning · correctness · packages/browser/src/deferred/offline.ts:112-116
The resend lease is held across POSTs that have no timeout
`run` takes the origin-wide `navigator.locks` lease and holds it for the whole `drain`, whose `fetch` (line 93) has no timeout. One tab with a stalled connection (the flaky-network case this feature exists for) keeps the lease, so no other tab of the origin can resend, and every `online` event in that tab joins the same `inflight` drain (line 124) instead of starting a new one — the stored batches then wait for the next page load. Abort the POST after a short budget (`AbortSignal.timeout`), which the existing `.catch(() => undefined)` already turns into "keep it for next time".
Suggested fix: Add `signal: AbortSignal.timeout(10_000)` to the drain `fetch`, or scope the lock to the store read/delete and not to the network call.

e5c997c · Updated on every push. Reply "won't fix" to dismiss a finding, or mention @maple to ask about one.

@maple-review-bot maple-review-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 inline note from Maple's review. The score and summary are in the review comment above.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The resend lease is held across POSTs that have no timeout

F3 · Warning · correctness

run takes the origin-wide navigator.locks lease and holds it for the whole drain, whose fetch (line 93) has no timeout. One tab with a stalled connection (the flaky-network case this feature exists for) keeps the lease, so no other tab of the origin can resend, and every online event in that tab joins the same inflight drain (line 124) instead of starting a new one — the stored batches then wait for the next page load. Abort the POST after a short budget (AbortSignal.timeout), which the existing .catch(() => undefined) already turns into "keep it for next time".

Add `signal: AbortSignal.timeout(10_000)` to the drain `fetch`, or scope the lock to the store read/delete and not to the network call.
Prompt for an AI agent
In `packages/browser/src/deferred/offline.ts:112-116`: The resend lease is held across POSTs that have no timeout.

`run` takes the origin-wide `navigator.locks` lease and holds it for the whole `drain`, whose `fetch` (line 93) has no timeout. One tab with a stalled connection (the flaky-network case this feature exists for) keeps the lease, so no other tab of the origin can resend, and every `online` event in that tab joins the same `inflight` drain (line 124) instead of starting a new one — the stored batches then wait for the next page load. Abort the POST after a short budget (`AbortSignal.timeout`), which the existing `.catch(() => undefined)` already turns into "keep it for next time".

Suggested fix: Add `signal: AbortSignal.timeout(10_000)` to the drain `fetch`, or scope the lock to the store read/delete and not to the network call.

Verify the problem exists at that location before changing it, and keep the fix to those lines.

@Makisuo
Makisuo merged commit 364b6c3 into main Sep 29, 2026
43 checks passed
@Makisuo
Makisuo deleted the feat/browser-sdk-offline-queue branch September 29, 2026 21:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant