diff --git a/packages/analytics-browser-test/test/index.test.ts b/packages/analytics-browser-test/test/index.test.ts index 5b05f7938b..3d052d601b 100644 --- a/packages/analytics-browser-test/test/index.test.ts +++ b/packages/analytics-browser-test/test/index.test.ts @@ -800,47 +800,6 @@ describe('integration', () => { second.done(); }); - test('should exhaust max retries', async () => { - const scope = nock(url).post(path).times(3).reply(500, { - code: 500, - }); - - await client.init(apiKey, { - logLevel: 0, - flushMaxRetries: 3, - defaultTracking, - fetchRemoteConfig: false, - }).promise; - const response = await client.track('test event').promise; - expect(response.event).toEqual({ - user_id: undefined, - device_id: uuid, - session_id: number, - time: number, - platform: 'Web', - language: 'en-US', - ip: '$remote', - insert_id: uuid, - partner_id: undefined, - event_type: 'test event', - event_properties: { - '[Amplitude] Page Domain': '', - '[Amplitude] Page Location': '', - '[Amplitude] Page Path': '', - '[Amplitude] Page Title': '', - '[Amplitude] Page URL': '', - '[Amplitude] Previous Page Location': '', - '[Amplitude] Previous Page Type': 'direct', - }, - event_id: 0, - library: library, - user_agent: userAgent, - }); - expect(response.code).toBe(500); - expect(response.message).toBe('Event rejected due to exceeded retry count'); - scope.done(); - }, 10000); - test('should handle missing api key', async () => { await client.init('', undefined, { logLevel: 0, diff --git a/packages/analytics-core/src/plugins/destination.ts b/packages/analytics-core/src/plugins/destination.ts index bcf3306e66..5e609572ce 100644 --- a/packages/analytics-core/src/plugins/destination.ts +++ b/packages/analytics-core/src/plugins/destination.ts @@ -32,6 +32,7 @@ import { EventCallback } from '../types/event-callback'; import { IDiagnosticsClient } from '../diagnostics/diagnostics-client'; import { isSuccessStatusCode } from '../utils/status-code'; import { getStacktrace } from '../utils/debug'; +import { isClientSide } from '../utils/environment'; export interface Context { event: Event; @@ -95,6 +96,11 @@ export class Destination implements DestinationPlugin { flushId: ReturnType | null = null; queue: Context[] = []; diagnosticsClient: IDiagnosticsClient | undefined; + // True for client SDKs (browser page or worker, Chrome extension, React Native), which can + // recover from an offline state via a network reconnect or a page reload / worker restart / + // app relaunch. False for the Node (server) SDK — a long-lived process with no such + // recovery — where events past the retry budget are dropped as before. + isClientSide = isClientSide(); constructor(context?: { diagnosticsClient: IDiagnosticsClient }) { this.diagnosticsClient = context?.diagnosticsClient; @@ -390,13 +396,49 @@ export class Destination implements DestinationPlugin { this.scheduleEvents(tryable); } + getRetryBackoff(attempts: number): number { + return this.retryTimeout * Math.pow(2, Math.max(0, attempts - 1)); + } + handleOtherResponse(list: Context[]) { - const later = list.map((context) => { - context.timeout = context.attempts * this.retryTimeout; - return context; - }); + let tryable: Context[]; + + if (this.isClientSide) { + // Client SDKs: mirror the mobile SDKs. Retry with exponential backoff, and once the + // retry budget is exhausted, go offline and keep the events instead of dropping + // them — this pauses all further flushing. They stay in the queue + storage; flushing + // resumes when the connectivity checker sees a `navigator`/NetInfo online event, or on + // page reload / worker restart / app relaunch (which resets config.offline and, since + // `attempts` is not persisted, retries from scratch). + tryable = []; + let isExceedingMaxRetries = false; + list.forEach((context) => { + context.attempts += 1; + if (context.attempts < this.config.flushMaxRetries) { + context.timeout = this.getRetryBackoff(context.attempts); + tryable.push(context); + } else { + isExceedingMaxRetries = true; + } + }); + if (isExceedingMaxRetries) { + this.config.offline = true; + this.config.loggerProvider.debug( + `Upload failed after ${this.config.flushMaxRetries} retries; going offline and pausing flush until the SDK reconnects or the page reloads.`, + ); + this.diagnosticsClient?.increment('offline.by.max.retries'); + } + } else { + // Server (Node) SDK: a long-lived process with no reconnect/reload to recover from + // offline, so keep the legacy behavior — linear backoff and drop events past the + // retry budget. + const later = list.map((context) => { + context.timeout = context.attempts * this.retryTimeout; + return context; + }); + tryable = this.removeEventsExceedFlushMaxRetries(later); + } - const tryable = this.removeEventsExceedFlushMaxRetries(later); this.scheduleEvents(tryable); } diff --git a/packages/analytics-core/src/utils/environment.ts b/packages/analytics-core/src/utils/environment.ts index d7284fb869..64361e5839 100644 --- a/packages/analytics-core/src/utils/environment.ts +++ b/packages/analytics-core/src/utils/environment.ts @@ -9,3 +9,26 @@ export function isReactNative(): boolean { const globalScope = getGlobalScope() as { navigator?: { product?: string } }; return globalScope?.navigator?.product === 'ReactNative'; } + +// Main-thread browser only. Workers have no `document` — use isWebWorker() for those. +export function isBrowser(): boolean { + const globalScope = getGlobalScope() as { document?: unknown } | undefined; + return typeof globalScope?.document !== 'undefined'; +} + +// True inside a dedicated / shared / service worker, e.g. a Manifest V3 extension background +// worker. `WorkerGlobalScope` is only exposed on worker globals, so its presence is already +// worker-specific; the instanceof check additionally rules out non-browser runtimes that expose +// web constructors on a server global. +export function isWebWorker(): boolean { + const globalScope = getGlobalScope() as { WorkerGlobalScope?: new () => unknown } | undefined; + if (!globalScope) { + return false; + } + const workerGlobalScope = globalScope.WorkerGlobalScope; + return typeof workerGlobalScope === 'function' && globalScope instanceof workerGlobalScope; +} + +export function isClientSide(): boolean { + return isReactNative() || isBrowser() || isWebWorker() || isChromeExtension(); +} diff --git a/packages/analytics-core/test/plugins/destination.test.ts b/packages/analytics-core/test/plugins/destination.test.ts index 9d3ff5748f..b0285f5971 100644 --- a/packages/analytics-core/test/plugins/destination.test.ts +++ b/packages/analytics-core/test/plugins/destination.test.ts @@ -7,6 +7,7 @@ import { Response } from '../../src/types/response'; import { API_KEY, useDefaultConfig } from '../helpers/default'; import { INVALID_API_KEY, + MAX_RETRIES_EXCEEDED_MESSAGE, MISSING_API_KEY_MESSAGE, SUCCESS_MESSAGE, UNEXPECTED_ERROR_MESSAGE, @@ -148,6 +149,103 @@ describe('destination', () => { }); }); + describe('offline on max retries (SDKW-42)', () => { + test('getRetryBackoff grows exponentially (bounded by flushMaxRetries, no interval cap)', () => { + const destination = new Destination(); + destination.retryTimeout = 1000; + expect(destination.getRetryBackoff(1)).toBe(1000); // 1000 * 2^0 + expect(destination.getRetryBackoff(2)).toBe(2000); // 1000 * 2^1 + expect(destination.getRetryBackoff(3)).toBe(4000); // 1000 * 2^2 + expect(destination.getRetryBackoff(5)).toBe(16000); // 1000 * 2^4 (last retry at default flushMaxRetries=5) + }); + + test('transient failures below the retry limit use exponential backoff and stay online', () => { + const destination = new Destination(); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 20, offline: false }; + destination.isClientSide = true; // client SDK (browser / RN) + destination.retryTimeout = 1000; + const schedule = jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + + destination.handleOtherResponse([context]); // attempts -> 1 + expect(context.timeout).toBe(1000); + destination.handleOtherResponse([context]); // attempts -> 2 + expect(context.timeout).toBe(2000); + destination.handleOtherResponse([context]); // attempts -> 3 + expect(context.timeout).toBe(4000); + + expect(destination.config.offline).toBe(false); + expect(schedule).toHaveBeenCalled(); + }); + + test('goes offline and retains events instead of dropping once the retry limit is hit', () => { + const diagnosticsClient = new DiagnosticsClient(API_KEY, getMockLogger()); + const increment = jest.spyOn(diagnosticsClient, 'increment').mockImplementation(jest.fn); + const destination = new Destination({ diagnosticsClient }); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 1, offline: false }; + destination.isClientSide = true; // client SDK (browser / RN) + const fulfillRequest = jest.spyOn(destination, 'fulfillRequest').mockImplementation(jest.fn); + jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + destination.queue = [context]; + + destination.handleOtherResponse([context]); // attempts 0 -> 1, 1 < 1 is false -> offline + + // Event is retained, not dropped. + expect(fulfillRequest).not.toHaveBeenCalled(); + expect(destination.queue).toContain(context); + // SDK went offline (mirrors mobile); attempts is left as-is (reset happens on reload + // because attempts is not persisted to storage). + expect(destination.config.offline).toBe(true); + expect(context.attempts).toBe(1); + // Diagnostics counter for going offline due to max retries. + expect(increment).toHaveBeenCalledWith('offline.by.max.retries'); + }); + + test('does not poll the server — offline stays set until reconnect or reload', () => { + jest.useFakeTimers(); + try { + const destination = new Destination(); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 1, offline: false }; + destination.isClientSide = true; // client SDK (browser / RN) + jest.spyOn(destination, 'fulfillRequest').mockImplementation(jest.fn); + jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + destination.queue = [context]; + + destination.handleOtherResponse([context]); + expect(destination.config.offline).toBe(true); + + // No self-recovery timer: advancing time does not bring the SDK back online. + jest.advanceTimersByTime(10 * 60 * 1000); + expect(destination.config.offline).toBe(true); + } finally { + jest.clearAllTimers(); + jest.useRealTimers(); + } + }); + + test('Node (server) SDK drops after max retries instead of going offline', () => { + const destination = new Destination(); + destination.config = { ...useDefaultConfig(), flushMaxRetries: 1, offline: false }; + destination.isClientSide = false; // server SDK (Node): no reconnect/reload + const fulfillRequest = jest.spyOn(destination, 'fulfillRequest').mockImplementation(jest.fn); + jest.spyOn(destination, 'schedule').mockImplementation(jest.fn); + const context: Context = { event: { event_type: 'a' }, attempts: 0, callback: () => undefined, timeout: 0 }; + + destination.handleOtherResponse([context]); // attempts 0 -> 1, 1 < 1 is false -> drop + + // Legacy behavior: event is dropped, SDK stays online. + expect(fulfillRequest).toHaveBeenCalledWith([context], 500, MAX_RETRIES_EXCEEDED_MESSAGE); + expect(destination.config.offline).toBe(false); + }); + + test('detects a server (Node/jest) environment as non-recovering on construction', () => { + // jest runs analytics-core in a node testEnvironment -> not a client SDK. + expect(new Destination().isClientSide).toBe(false); + }); + }); + describe('schedule', () => { beforeEach(() => { jest.useFakeTimers(); diff --git a/packages/analytics-core/test/utils/environment.test.ts b/packages/analytics-core/test/utils/environment.test.ts index a87371d9b1..9c33029ae9 100644 --- a/packages/analytics-core/test/utils/environment.test.ts +++ b/packages/analytics-core/test/utils/environment.test.ts @@ -1,8 +1,17 @@ import * as analyticsCoreModule from '../../src/index'; +// Not part of the public API — imported from the module under test directly. +import { isBrowser, isClientSide, isWebWorker } from '../../src/utils/environment'; import * as globalScopeModule from '../../src/global-scope'; type ChromeStub = { runtime?: { id?: string | number } }; +// Stands in for a worker global: an instance of the constructor it also exposes. +class FakeWorkerGlobalScope { + WorkerGlobalScope = FakeWorkerGlobalScope; +} + +const workerGlobalScope = () => new FakeWorkerGlobalScope() as unknown as typeof globalThis; + describe('isChromeExtension', () => { const originalChrome = (globalThis as typeof globalThis & { chrome?: ChromeStub }).chrome; @@ -72,3 +81,103 @@ describe('isReactNative', () => { expect(analyticsCoreModule.isReactNative()).toBe(true); }); }); + +describe('isBrowser', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isBrowser()).toBe(false); + }); + + test('returns false when document is undefined', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isBrowser()).toBe(false); + }); + + test('returns true when document is defined', () => { + getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); + expect(isBrowser()).toBe(true); + }); +}); + +describe('isWebWorker', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns false when globalScope is undefined', () => { + getGlobalScopeSpy.mockReturnValue(undefined); + expect(isWebWorker()).toBe(false); + }); + + test('returns false when WorkerGlobalScope is not exposed', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isWebWorker()).toBe(false); + }); + + test('returns false when WorkerGlobalScope is exposed but the scope is not a worker', () => { + // e.g. a server runtime that exposes web constructors on its global. + getGlobalScopeSpy.mockReturnValue({ + WorkerGlobalScope: FakeWorkerGlobalScope, + } as unknown as typeof globalThis); + expect(isWebWorker()).toBe(false); + }); + + test('returns true inside a worker global scope', () => { + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(isWebWorker()).toBe(true); + }); +}); + +describe('isClientSide', () => { + let getGlobalScopeSpy: jest.SpyInstance; + + beforeEach(() => { + getGlobalScopeSpy = jest.spyOn(globalScopeModule, 'getGlobalScope'); + }); + + afterEach(() => { + getGlobalScopeSpy.mockRestore(); + }); + + test('returns true in a React Native environment', () => { + getGlobalScopeSpy.mockReturnValue({ navigator: { product: 'ReactNative' } } as unknown as typeof globalThis); + expect(isClientSide()).toBe(true); + }); + + test('returns true in a browser environment', () => { + getGlobalScopeSpy.mockReturnValue({ document: {} } as unknown as typeof globalThis); + expect(isClientSide()).toBe(true); + }); + + test('returns true in a document-less browser worker', () => { + // e.g. a Manifest V3 extension background service worker. + getGlobalScopeSpy.mockReturnValue(workerGlobalScope()); + expect(isClientSide()).toBe(true); + }); + + test('returns true in a Chrome extension without document', () => { + getGlobalScopeSpy.mockReturnValue({ chrome: { runtime: { id: 'ext-abc' } } } as unknown as typeof globalThis); + expect(isClientSide()).toBe(true); + }); + + test('returns false in a server (Node) environment', () => { + getGlobalScopeSpy.mockReturnValue({} as typeof globalThis); + expect(isClientSide()).toBe(false); + }); +}); diff --git a/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts b/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts index bbd3b95fa8..e1c5217079 100644 --- a/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts +++ b/packages/analytics-react-native/test/migration/remnant-data-migration.test.ts @@ -230,6 +230,9 @@ describe('migration', () => { migrateLegacyData: false, loggerProvider, instanceName: 'test-instance', + attribution: { + disabled: true, + }, }).promise; expect(client.getDeviceId()).not.toEqual(deviceId); expect(client.getUserId()).not.toEqual(userId); diff --git a/packages/analytics-react-native/test/react-native-client.test.ts b/packages/analytics-react-native/test/react-native-client.test.ts index 04ef27f586..db6650e6fa 100644 --- a/packages/analytics-react-native/test/react-native-client.test.ts +++ b/packages/analytics-react-native/test/react-native-client.test.ts @@ -374,7 +374,9 @@ describe('react-native-client', () => { describe('reset', () => { test('should reset user id and generate new device id config', async () => { const client = new AmplitudeReactNative(); - await client.init(API_KEY).promise; + await client.init(API_KEY, undefined, { + ...attributionConfig, + }).promise; client.setUserId(USER_ID); client.setDeviceId(DEVICE_ID); expect(client.getUserId()).toBe(USER_ID);