From aafdb4706f82b37fbfdaebf4cfc08ca9a5900993 Mon Sep 17 00:00:00 2001 From: Christopher Houdlette Date: Thu, 9 Apr 2026 15:47:10 -0600 Subject: [PATCH] feat: debounce network reachability online transitions (2s) Online transitions are debounced by 2s to prevent churn from rapid network flapping. Offline transitions remain immediate. The debounce lives in MetaRouterAnalyticsClient's onStatusChange callback, keeping NetworkReachability/StubNetworkMonitor as low-level fire-on-every- transition abstractions. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../MetaRouterAnalyticsClient.test.ts | 141 ++++++++++++++++++ src/analytics/MetaRouterAnalyticsClient.ts | 63 +++++++- src/analytics/dispatcher.ts | 6 + src/analytics/network/NetworkReachability.ts | 6 + src/analytics/network/StubNetworkMonitor.ts | 32 ++++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 src/analytics/network/NetworkReachability.ts create mode 100644 src/analytics/network/StubNetworkMonitor.ts 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); + } +}