Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions src/analytics/MetaRouterAnalyticsClient.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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');
});
});
});
63 changes: 61 additions & 2 deletions src/analytics/MetaRouterAnalyticsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<typeof setTimeout> | 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() === '') {
Expand Down Expand Up @@ -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: () =>
Expand Down Expand Up @@ -118,6 +134,7 @@ export class MetaRouterAnalyticsClient {

this.queue = this.dispatcher.getQueueRef();
this.persistentQueue = new PersistentEventQueue(this.dispatcher);
this.networkMonitor = deps?.networkMonitor ?? null;
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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';
}
Expand Down Expand Up @@ -458,6 +507,7 @@ export class MetaRouterAnalyticsClient {
circuitRemainingMs: d.circuitRemainingMs,
maxQueueBytes: d.maxQueueBytes,
tracingEnabled: this.tracingEnabled,
networkStatus: this.networkStatus,
rehydratedEvents: this.persistentQueue.rehydratedEvents,
};
}
Expand All @@ -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?.();
Expand Down
6 changes: 6 additions & 0 deletions src/analytics/dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions src/analytics/network/NetworkReachability.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export type NetworkStatus = 'connected' | 'disconnected';

export interface NetworkReachability {
onStatusChange(callback: (status: NetworkStatus) => void): () => void;
stop(): void;
}
32 changes: 32 additions & 0 deletions src/analytics/network/StubNetworkMonitor.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading