Skip to content
Merged
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
139 changes: 134 additions & 5 deletions src/analytics/MetaRouterAnalyticsClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ describe('MetaRouterAnalyticsClient', () => {
});

describe('network awareness', () => {
beforeEach(() => {
// Clear leftover timers (flush intervals) from previous tests' client instances
jest.clearAllTimers();
});

it('getDebugInfo includes networkStatus', async () => {
const monitor = new StubNetworkMonitor('connected');
const client = new MetaRouterAnalyticsClient(opts, {
Expand Down Expand Up @@ -633,7 +638,7 @@ describe('MetaRouterAnalyticsClient', () => {
expect(fetch).toHaveBeenCalled();
});

it('offline -> online transition resets circuit breaker and triggers flush', async () => {
it('offline -> online transition resets circuit breaker and triggers flush after debounce', async () => {
const monitor = new StubNetworkMonitor('connected');
const client = new MetaRouterAnalyticsClient(opts, {
networkMonitor: monitor,
Expand All @@ -649,16 +654,15 @@ describe('MetaRouterAnalyticsClient', () => {

// Flush should not send (offline)
await client.flush();
// Events should still be in queue since network is unavailable
// (the dispatcher guard prevents HTTP calls)

// Go online — should trigger flush
// Go online — starts 2s debounce
(global as any).fetch = jest
.fn()
.mockResolvedValue({ ok: true, status: 200 });
monitor.simulate('connected');

// Allow the async flush to complete
// Advance past debounce window then allow flush to settle
jest.advanceTimersByTime(2000);
await new Promise((resolve) => setTimeout(resolve, 50));
expect(fetch).toHaveBeenCalled();
});
Expand All @@ -674,6 +678,131 @@ describe('MetaRouterAnalyticsClient', () => {
await client.reset();
expect(stopSpy).toHaveBeenCalled();
});

it('online transition is debounced — rapid flapping produces single flush', async () => {
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.

Good test!

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();
});
});

describe('tracing', () => {
Expand Down
28 changes: 13 additions & 15 deletions src/analytics/MetaRouterAnalyticsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@ import { PersistentEventQueue } from './persistence/PersistentEventQueue';
import {
NetworkMonitor,
type NetworkReachability,
type NetworkStatus,
} from './utils/networkMonitor';
import { DebouncedNetworkMonitor } from './utils/debouncedNetworkMonitor';

/**
* Analytics client for MetaRouter.
Expand All @@ -42,8 +42,7 @@ export class MetaRouterAnalyticsClient {
private dispatcher!: Dispatcher;
private persistentQueue!: PersistentEventQueue;
private tracingEnabled: boolean = false;
private networkMonitor: NetworkReachability;
private networkStatus: NetworkStatus = 'connected';
private networkMonitor: DebouncedNetworkMonitor;
private unsubscribeNetwork: (() => void) | null = null;

/**
Expand Down Expand Up @@ -84,7 +83,9 @@ export class MetaRouterAnalyticsClient {

setDebugLogging(options.debug ?? false);
this.identityManager = new IdentityManager();
this.networkMonitor = deps?.networkMonitor ?? new NetworkMonitor();
this.networkMonitor = new DebouncedNetworkMonitor(
deps?.networkMonitor ?? new NetworkMonitor()
);
this.maxQueueBytes = options.maxQueueBytes ?? this.maxQueueBytes;
this.dispatcher = new Dispatcher({
maxQueueBytes: this.maxQueueBytes,
Expand All @@ -93,7 +94,8 @@ export class MetaRouterAnalyticsClient {
flushIntervalSeconds: this.flushIntervalSeconds,
baseRetryDelayMs: 1000,
maxRetryDelayMs: 8000,
isNetworkAvailable: () => this.networkStatus === 'connected',
isNetworkAvailable: () =>
this.networkMonitor.currentStatus === 'connected',
endpoint: (path) => this.endpoint(path),
fetchWithTimeout: (url, init, timeoutMs) =>
this.fetchWithTimeout(url, init, timeoutMs),
Expand Down Expand Up @@ -173,18 +175,14 @@ export class MetaRouterAnalyticsClient {

this.lifecycle = 'ready';

// Set initial network state and subscribe to changes
this.networkStatus = this.networkMonitor.currentStatus;
// Subscribe to debounced network transitions
this.unsubscribeNetwork = this.networkMonitor.onStatusChange(
(status) => {
const wasOffline = this.networkStatus === 'disconnected';
this.networkStatus = status;

if (wasOffline && status === 'connected') {
log('Network connectivity restored — resuming flush');
if (status === 'connected') {
log('Network connectivity stable — resuming flush');
this.dispatcher.resetCircuitBreaker();
void this.flush();
} else if (status === 'disconnected') {
} else {
log('Network connectivity lost — pausing HTTP attempts');
}
}
Expand Down Expand Up @@ -490,7 +488,7 @@ export class MetaRouterAnalyticsClient {
maxQueueBytes: d.maxQueueBytes,
tracingEnabled: this.tracingEnabled,
rehydratedEvents: this.persistentQueue.rehydratedEvents,
networkStatus: this.networkStatus,
networkStatus: this.networkMonitor.currentStatus,
};
}

Expand All @@ -511,7 +509,7 @@ export class MetaRouterAnalyticsClient {
// Flip lifecycle first so other paths see we're resetting
this.lifecycle = 'resetting';

// Stop network monitoring
// Stop network monitoring (cancels pending debounce internally)
this.unsubscribeNetwork?.();
this.unsubscribeNetwork = null;
this.networkMonitor.stop();
Expand Down
Loading
Loading