diff --git a/src/analytics/MetaRouterAnalyticsClient.test.ts b/src/analytics/MetaRouterAnalyticsClient.test.ts index 6f5edc3..5af7c86 100644 --- a/src/analytics/MetaRouterAnalyticsClient.test.ts +++ b/src/analytics/MetaRouterAnalyticsClient.test.ts @@ -1,6 +1,7 @@ import { MetaRouterAnalyticsClient } from './MetaRouterAnalyticsClient'; import type { InitOptions } from './types'; import { AppState } from 'react-native'; +import { StubNetworkMonitor } from './network/StubNetworkMonitor'; const mockAddEventListener = jest.fn(); jest @@ -746,4 +747,144 @@ describe('MetaRouterAnalyticsClient', () => { ); }); }); + + describe('network awareness', () => { + beforeEach(() => { + // Clear leftover timers (flush intervals) from previous tests' client instances + jest.clearAllTimers(); + }); + + it('online transition is debounced — rapid flapping produces single flush', async () => { + const monitor = new StubNetworkMonitor('connected'); + const client = new MetaRouterAnalyticsClient(opts, { + networkMonitor: monitor, + }); + await client.init(); + + // Track an event, then go offline + client.track('Event 1'); + monitor.simulate('disconnected'); + + // Rapid flap: online -> offline -> online -> offline -> online + monitor.simulate('connected'); + jest.advanceTimersByTime(500); + monitor.simulate('disconnected'); + monitor.simulate('connected'); + jest.advanceTimersByTime(500); + monitor.simulate('disconnected'); + monitor.simulate('connected'); + + // Reset fetch mock to isolate debounce-triggered flush + (global as any).fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + + // Before debounce window: no flush should have fired + jest.advanceTimersByTime(1999); + expect(fetch).not.toHaveBeenCalled(); + + // After debounce window: exactly one flush + jest.advanceTimersByTime(1); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetch).toHaveBeenCalledTimes(1); + }); + + it('debounce timer is cancelled when device goes back offline', async () => { + const monitor = new StubNetworkMonitor('connected'); + const client = new MetaRouterAnalyticsClient(opts, { + networkMonitor: monitor, + }); + await client.init(); + + client.track('Event 1'); + monitor.simulate('disconnected'); + + // Come back online + monitor.simulate('connected'); + jest.advanceTimersByTime(1500); // 1.5s into 2s debounce + + // Go offline again before debounce fires + monitor.simulate('disconnected'); + + // Reset fetch to isolate + (global as any).fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + + // Advance past where the debounce would have fired + jest.advanceTimersByTime(2000); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetch).not.toHaveBeenCalled(); + + // Events should still be queued + expect(client.queue.length).toBeGreaterThan(0); + }); + + it('clean online transition flushes after 2s debounce', async () => { + const monitor = new StubNetworkMonitor('connected'); + const client = new MetaRouterAnalyticsClient(opts, { + networkMonitor: monitor, + }); + await client.init(); + + client.track('Event 1'); + monitor.simulate('disconnected'); + + (global as any).fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + monitor.simulate('connected'); + + // Should NOT flush immediately + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetch).not.toHaveBeenCalled(); + + // Should flush after 2s + jest.advanceTimersByTime(2000); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetch).toHaveBeenCalled(); + }); + + it('offline transition is immediate (not debounced)', async () => { + const monitor = new StubNetworkMonitor('connected'); + const client = new MetaRouterAnalyticsClient(opts, { + networkMonitor: monitor, + }); + await client.init(); + + monitor.simulate('disconnected'); + + const debugInfo = await client.getDebugInfo(); + expect(debugInfo.networkStatus).toBe('disconnected'); + }); + + it('reset() cancels pending online debounce timer', async () => { + const monitor = new StubNetworkMonitor('connected'); + const client = new MetaRouterAnalyticsClient(opts, { + networkMonitor: monitor, + }); + await client.init(); + + monitor.simulate('disconnected'); + monitor.simulate('connected'); // starts 2s debounce + + (global as any).fetch = jest + .fn() + .mockResolvedValue({ ok: true, status: 200 }); + await client.reset(); + + // Advance past debounce — should NOT flush (timer was cancelled by reset) + jest.advanceTimersByTime(3000); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('networkStatus shows connected by default without a monitor', async () => { + const client = new MetaRouterAnalyticsClient(opts); + await client.init(); + + const debugInfo = await client.getDebugInfo(); + expect(debugInfo.networkStatus).toBe('connected'); + }); + }); }); diff --git a/src/analytics/MetaRouterAnalyticsClient.ts b/src/analytics/MetaRouterAnalyticsClient.ts index 63a68fc..af7f3d2 100644 --- a/src/analytics/MetaRouterAnalyticsClient.ts +++ b/src/analytics/MetaRouterAnalyticsClient.ts @@ -13,6 +13,16 @@ import { import CircuitBreaker from './utils/circuitBreaker'; import Dispatcher from './dispatcher'; import { PersistentEventQueue } from './persistence/PersistentEventQueue'; +import type { + NetworkReachability, + NetworkStatus, +} from './network/NetworkReachability'; + +const ONLINE_DEBOUNCE_MS = 2_000; + +export interface AnalyticsClientDeps { + networkMonitor?: NetworkReachability; +} /** * Analytics client for MetaRouter. @@ -37,12 +47,16 @@ export class MetaRouterAnalyticsClient { private dispatcher!: Dispatcher; private persistentQueue!: PersistentEventQueue; private tracingEnabled: boolean = false; + private networkMonitor: NetworkReachability | null = null; + private networkStatus: NetworkStatus = 'connected'; + private onlineDebounceTimer: ReturnType | null = null; + private unsubscribeNetwork: (() => void) | null = null; /** * Initializes the analytics client with the provided options. * @param options - The initialization options. */ - constructor(options: InitOptions) { + constructor(options: InitOptions, deps?: AnalyticsClientDeps) { const { writeKey, ingestionHost, flushIntervalSeconds } = options; if (!writeKey || typeof writeKey !== 'string' || writeKey.trim() === '') { @@ -85,7 +99,9 @@ export class MetaRouterAnalyticsClient { fetchWithTimeout: (url, init, timeoutMs) => this.fetchWithTimeout(url, init, timeoutMs), canSend: () => - this.lifecycle === 'ready' && !!this.identityManager.getAnonymousId(), + this.lifecycle === 'ready' && + !!this.identityManager.getAnonymousId() && + this.networkStatus === 'connected', isOperational: () => this.lifecycle === 'ready', isTracingEnabled: () => this.tracingEnabled, createBreaker: () => @@ -118,6 +134,7 @@ export class MetaRouterAnalyticsClient { this.queue = this.dispatcher.getQueueRef(); this.persistentQueue = new PersistentEventQueue(this.dispatcher); + this.networkMonitor = deps?.networkMonitor ?? null; } /** @@ -159,6 +176,7 @@ export class MetaRouterAnalyticsClient { ); this.lifecycle = 'ready'; + this.setupNetworkMonitor(); log('MetaRouter SDK initialized'); // Flush immediately so rehydrated events ship on cold start @@ -185,6 +203,37 @@ export class MetaRouterAnalyticsClient { this.dispatcher.start(); } + private setupNetworkMonitor(): void { + if (!this.networkMonitor) return; + + this.unsubscribeNetwork = this.networkMonitor.onStatusChange((status) => { + const wasOffline = this.networkStatus === 'disconnected'; + + if (status === 'disconnected') { + // Offline: act immediately, cancel any pending online debounce + if (this.onlineDebounceTimer) { + clearTimeout(this.onlineDebounceTimer); + this.onlineDebounceTimer = null; + } + this.networkStatus = status; + log('Network connectivity lost — pausing HTTP attempts'); + } else if (wasOffline && status === 'connected') { + // Online: debounce — only act after connectivity is stable for 2s + log('Network connectivity detected — debouncing for stability'); + if (this.onlineDebounceTimer) { + clearTimeout(this.onlineDebounceTimer); + } + this.onlineDebounceTimer = setTimeout(() => { + this.onlineDebounceTimer = null; + this.networkStatus = status; + log('Network connectivity stable — resuming flush'); + this.dispatcher.resetCircuitBreaker(); + void this.flush(); + }, ONLINE_DEBOUNCE_MS); + } + }); + } + private isReady(): boolean { return this.lifecycle === 'ready'; } @@ -458,6 +507,7 @@ export class MetaRouterAnalyticsClient { circuitRemainingMs: d.circuitRemainingMs, maxQueueBytes: d.maxQueueBytes, tracingEnabled: this.tracingEnabled, + networkStatus: this.networkStatus, rehydratedEvents: this.persistentQueue.rehydratedEvents, }; } @@ -479,6 +529,15 @@ export class MetaRouterAnalyticsClient { // Flip lifecycle first so other paths see we're resetting this.lifecycle = 'resetting'; + // Stop network monitoring and cancel pending debounce + if (this.onlineDebounceTimer) { + clearTimeout(this.onlineDebounceTimer); + this.onlineDebounceTimer = null; + } + this.unsubscribeNetwork?.(); + this.unsubscribeNetwork = null; + this.networkMonitor?.stop(); + // Stop background work this.dispatcher.stop(); this.appStateSubscription?.remove?.(); diff --git a/src/analytics/dispatcher.ts b/src/analytics/dispatcher.ts index 39ae326..4314c89 100644 --- a/src/analytics/dispatcher.ts +++ b/src/analytics/dispatcher.ts @@ -79,6 +79,12 @@ export default class Dispatcher { this.maxBatchSize = this.initialMaxBatchSize; } + resetCircuitBreaker(): void { + this.consecutiveRetries = 0; + this.circuit = this.opts.createBreaker(); + this.maxBatchSize = this.initialMaxBatchSize; + } + /** * Retry floor: exponential backoff independent of circuit breaker. * Applies from the very first failure so retries aren't immediate while circuit is closed. diff --git a/src/analytics/network/NetworkReachability.ts b/src/analytics/network/NetworkReachability.ts new file mode 100644 index 0000000..52c8965 --- /dev/null +++ b/src/analytics/network/NetworkReachability.ts @@ -0,0 +1,6 @@ +export type NetworkStatus = 'connected' | 'disconnected'; + +export interface NetworkReachability { + onStatusChange(callback: (status: NetworkStatus) => void): () => void; + stop(): void; +} diff --git a/src/analytics/network/StubNetworkMonitor.ts b/src/analytics/network/StubNetworkMonitor.ts new file mode 100644 index 0000000..e3d47b7 --- /dev/null +++ b/src/analytics/network/StubNetworkMonitor.ts @@ -0,0 +1,32 @@ +import type { NetworkReachability, NetworkStatus } from './NetworkReachability'; + +/** + * Test double for network monitoring. Fires status changes synchronously + * via `simulate()` — no internal timers or debounce. + */ +export class StubNetworkMonitor implements NetworkReachability { + private callback: ((status: NetworkStatus) => void) | null = null; + private status: NetworkStatus; + + constructor(initialStatus: NetworkStatus = 'connected') { + this.status = initialStatus; + } + + onStatusChange(callback: (status: NetworkStatus) => void): () => void { + this.callback = callback; + return () => { + this.callback = null; + }; + } + + stop(): void { + this.callback = null; + } + + /** Simulate a network transition. Only fires if status actually changes. */ + simulate(status: NetworkStatus): void { + if (status === this.status) return; + this.status = status; + this.callback?.(status); + } +}