Skip to content

feat(analytics-core): retain events and go offline after max retries on client SDKs - #1908

Open
Mercy811 wants to merge 3 commits into
mainfrom
xinyiye/sdkw-42-browser-sdk-go-offline-persist-after-max-retries
Open

feat(analytics-core): retain events and go offline after max retries on client SDKs#1908
Mercy811 wants to merge 3 commits into
mainfrom
xinyiye/sdkw-42-browser-sdk-go-offline-persist-after-max-retries

Conversation

@Mercy811

@Mercy811 Mercy811 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Summary

Follow-up to the 2026-07-24 AWS us-west-2 event-server outage (SDKW-42, SDKRN-55).

When the event server is unreachable (5xx / network errors / timeouts), the Browser and React Native SDKs dropped events once the retry budget was exhausted. Offline mode is only entered on a device-network signal (navigator.onLine / NetInfo), so a server-only outage — where the device network stays up — never trips it and events are dropped after retries.

This change makes analytics-core's Destination mirror the mobile SDKs (Amplitude-Swift EventPipeline) — for client SDKs only.

What changes (packages/analytics-core/src/plugins/destination.ts)

  1. Exponential backoff for transient-failure retries — retryTimeout * 2^(attempts-1) (was linear attempts * retryTimeout). No interval cap: the retry count (flushMaxRetries, default 5 on browser/RN) already bounds it (largest wait ~8s before going offline at defaults).
  2. Go offline + retain instead of drop. Once an event exhausts flushMaxRetries on a transient failure, set config.offline = true and keep the event (it stays in the queue + storage) rather than dropping it via removeEventsExceedFlushMaxRetries.

Client vs. server gating (important)

The Destination plugin lives in analytics-core and is shared by the Node (server) SDK too. Node is a long-lived process with no reload/relaunch and no connectivity checker, so flipping it offline would strand it forever. The go-offline behavior is therefore gated to client SDKs only, via a new isClientSide() helper (isReactNative() || isBrowser(), alongside the existing isReactNative() in utils/environment.ts), resolved once as a field initializer (the runtime environment is fixed for the process lifetime):

  • Browser / React Native → go offline + retain (new behavior).
  • Node → unchanged: drop events past the retry budget.

Recovery (client SDKs — mobile parity)

Deliberately no server polling. Client SDKs come back online exactly like mobile:

  • the connectivity checker flips config.offline = false on a navigator / NetInfo online event, or
  • the page reloads / app relaunchesconfig.offline is in-memory so it resets, and because context.attempts is not persisted, every stored event is retried from a fresh budget.

Events are retained across the outage and delivered on recovery — only storage exhaustion would lose them.

Scope / behavior notes

  • Only the transient-failure path (handleOtherResponse — 5xx / network / timeout) changes. Terminal per-event drops are untouched: 400 invalid API key, server-flagged invalid events, and 413 single-event still drop via removeEventsExceedFlushMaxRetries.
  • OfflineDisabled is not special-cased in the destination (it only gates installing the connectivity plugin, browser-client.ts:312); the destination just reads/writes config.offline. On a client SDK that sets offline: OfflineDisabled, a max-retry still flips it to true and retains — same as mobile.

Open question

  • Storage-exhaustion eviction ("retry forever unless out of space") is not implemented here — events are retained in the in-memory queue + best-effort storage. Follow-up or in-scope?

Refs SDKW-42, SDKRN-55.

🤖 Generated with Claude Code


Note

Medium Risk
Changes core event upload retry/drop behavior for all browser and React Native customers during outages; Node path is explicitly preserved but shared Destination code is security-sensitive for data loss.

Overview
Client SDKs (browser / React Native) no longer drop events when transient upload failures exhaust flushMaxRetries. Destination.handleOtherResponse now uses exponential backoff (getRetryBackoff), and on the last failed attempt sets config.offline = true, keeps events in the queue/storage, and records offline.by.max.retries—pausing flush until connectivity recovery or reload (no server polling).

Node keeps prior behavior: linear backoff and drop via removeEventsExceedFlushMaxRetries. Branching uses new isBrowser / isClientSide helpers (exported from analytics-core).

Tests cover the new retry/offline paths; the browser integration test that expected max-retry rejection was removed. RN tests disable attribution where needed for stability.

Reviewed by Cursor Bugbot for commit 5597e11. Configure here.

@linear-code

linear-code Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

SDKW-42

SDKRN-55

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown

size-limit report 📦

Path Size
packages/analytics-browser/lib/scripts/amplitude-min.js.gz 61.51 KB (+0.39% 🔺)
packages/session-replay-browser/lib/scripts/session-replay-browser-min.js.gz 134.97 KB (0%)
packages/unified/lib/scripts/amplitude-min.umd.js.gz 215.59 KB (+0.11% 🔺)
@amplitude/element-selector (gzipped esm) 2.67 KB (0%)

@Mercy811
Mercy811 force-pushed the xinyiye/sdkw-42-browser-sdk-go-offline-persist-after-max-retries branch 3 times, most recently from 4a720cd to 5362fdc Compare July 25, 2026 00:25
@Mercy811 Mercy811 changed the title feat(analytics-core): retain events and go offline after max retries instead of dropping feat(analytics-core): retain events and go offline after max retries on client SDKs Jul 25, 2026
@Mercy811
Mercy811 force-pushed the xinyiye/sdkw-42-browser-sdk-go-offline-persist-after-max-retries branch 6 times, most recently from 309a934 to d510e31 Compare July 27, 2026 17:43
…on client SDKs

When the event server is unreachable (5xx / network errors / timeouts), the
Browser and React Native SDKs dropped events once the retry budget was exhausted.
Offline mode is only entered on a device-network signal (navigator.onLine /
NetInfo), so a server-only outage never trips it. During the 2026-07-24 AWS
us-west-2 outage this dropped ~0.4% of web events.

Mirror the mobile SDKs (Amplitude-Swift EventPipeline), for client SDKs only:
- Retry transient failures with exponential backoff (retryTimeout * 2^(attempts-1))
  instead of linear. The retry count (flushMaxRetries, default 5 on browser/RN)
  bounds it, so no interval cap is needed.
- Once the retry budget is exhausted, set config.offline = true and retain the
  events instead of dropping them. They stay in the queue and on storage; flushing
  resumes when the connectivity checker sees a navigator/NetInfo online event or the
  page reloads / app relaunches (which resets config.offline and, since
  context.attempts is not persisted, retries every stored event from scratch).
  No server polling.

Gate this on the environment via a new isClientSide() helper (isReactNative() ||
isBrowser()). The Node (server) SDK is a long-lived process with no reconnect/reload
recovery, so it keeps the legacy behavior of dropping events past the retry budget.

Terminal per-event drops are unchanged (400 invalid API key, server-flagged events,
413 single-event).

Update tests for the new client behavior: drop the browser exhaust-retries drop
assertion, and stabilize RN init by disabling attribution where it caused flakes.

Refs SDKW-42, SDKRN-55.

Co-authored-by: Cursor <cursoragent@cursor.com>
@Mercy811
Mercy811 force-pushed the xinyiye/sdkw-42-browser-sdk-go-offline-persist-after-max-retries branch from d510e31 to 03fc4a8 Compare July 27, 2026 18:54
@Mercy811

Copy link
Copy Markdown
Contributor Author

bugbot run

@cursor cursor 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.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5597e11. Configure here.

@Mercy811
Mercy811 marked this pull request as ready for review July 27, 2026 20:42
@Mercy811
Mercy811 requested a review from a team as a code owner July 27, 2026 20:42

@chatgpt-codex-connector chatgpt-codex-connector 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5597e116b7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/analytics-core/src/utils/environment.ts
isClientSide() used `document` as the browser discriminator, so a document-less
browser context — notably a Manifest V3 extension background service worker, as
in examples/browser/chrome-ext — fell through to the Node branch and dropped
events past the retry budget instead of retaining them.

Add isWebWorker(), which confirms the global scope is an instance of its own
WorkerGlobalScope. That constructor is exposed only on worker globals, and the
instanceof check additionally rules out non-browser runtimes that expose web
constructors on a server global. OR it into isClientSide() along with the
existing isChromeExtension().

Keep isBrowser/isClientSide/isWebWorker out of the public API — nothing outside
analytics-core uses them.

Refs SDKW-42, SDKRN-55.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isExceedingMaxRetries = true;
}
});
if (isExceedingMaxRetries) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

By my spreadsheet calculations, it would (by default) take ~34 minutes before it stops trying and then goes "offline".

To confirm, we still save events to offline storage (storageProvider) before they go offline? It's just that marking the client as "offline" prevents the unsent events from being flushed whenever there's a failure?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

could you share your calculation, I get 16s from event tracked to offline with exponential backoff:

  1. track 1s flush, 1s
  2. failure 1, 2^0 = 1s
  3. failure 2, 2s
  4. failure 3, 4s
  5. failure 4, 8s
  6. failure 5, offline
    total = 1 + 1 + 2 + 4 + 8 = 16s

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

To confirm, we still save events to offline storage (storageProvider) before they go offline? It's just that marking the client as "offline" prevents the unsent events from being flushed whenever there's a failure?

Yes, saveEvents() is called every time on destination.execute()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Isn't it 12 retries? When you count up to 12 that's how I calculate that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

that's probably for node, browser default is at

public flushMaxRetries: number = 5,

@daniel-graham-amplitude
daniel-graham-amplitude force-pushed the xinyiye/sdkw-42-browser-sdk-go-offline-persist-after-max-retries branch 2 times, most recently from 79503fe to ba2a055 Compare July 28, 2026 16:30

test('should exhaust max retries', async () => {
const scope = nock(url).post(path).times(3).reply(500, {
code: 500,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

it looks like every response that goes through handleOtherResponse gets the new offline logic - are there any responses that we should not retry?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

good catch!

  • before: the logic is always retry except knowing to drop (invalid, missing fields etc. identified by server) and then drop.
  • after: the logic is retry and then offline so a bad event not identified by server can cause the SDK offline forever. But it seems that this logic works well for mobile SDKs. So we could safely take the assumption that server will tell SDKs which events to drop and the should retry rest events

We shouldn't retry non-tryable 5xx for example #1630. Thanks @daniel-graham-amplitude for bring this up.

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.

3 participants