Skip to content

Commit

Permalink
feat: add offline mode (#644)
Browse files Browse the repository at this point in the history
  • Loading branch information
Mercy811 authored Jan 24, 2024
1 parent 0a88f63 commit f2cd717
Show file tree
Hide file tree
Showing 18 changed files with 322 additions and 2 deletions.
5 changes: 5 additions & 0 deletions packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
Identify as IIdentify,
Revenue as IRevenue,
TransportType,
OfflineDisabled,
} from '@amplitude/analytics-types';
import { convertProxyObjectToRealObject, isInstanceProxy } from './utils/snippet-helper';
import { Context } from './plugins/context';
Expand All @@ -31,6 +32,7 @@ import { formInteractionTracking } from './plugins/form-interaction-tracking';
import { fileDownloadTracking } from './plugins/file-download-tracking';
import { DEFAULT_SESSION_END_EVENT, DEFAULT_SESSION_START_EVENT } from './constants';
import { detNotify } from './det-notification';
import { networkConnectivityCheckerPlugin } from './plugins/network-connectivity-checker';

export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down Expand Up @@ -86,6 +88,9 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {

// Step 4: Install plugins
// Do not track any events before this
if (this.config.offline !== OfflineDisabled) {
await this.add(networkConnectivityCheckerPlugin()).promise;
}
await this.add(new Destination()).promise;
await this.add(new Context()).promise;
await this.add(new IdentityEventSender()).promise;
Expand Down
3 changes: 3 additions & 0 deletions packages/analytics-browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
IngestionMetadata,
IdentityStorageType,
ServerZoneType,
OfflineDisabled,
} from '@amplitude/analytics-types';
import { Config, Logger, MemoryStorage, UUID } from '@amplitude/analytics-core';
import { CookieStorage, getCookieName, FetchTransport, getQueryParams } from '@amplitude/analytics-client-common';
Expand Down Expand Up @@ -60,6 +61,7 @@ export class BrowserConfig extends Config implements IBrowserConfig {
public loggerProvider: ILogger = new Logger(),
public logLevel: LogLevel = LogLevel.Warn,
public minIdLength?: number,
public offline: boolean | typeof OfflineDisabled = false,
optOut = false,
public partnerId?: string,
public plan?: Plan,
Expand Down Expand Up @@ -236,6 +238,7 @@ export const useBrowserConfig = async (
options.loggerProvider,
options.logLevel,
options.minIdLength,
options.offline,
optOut,
options.partnerId,
options.plan,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { getGlobalScope } from '@amplitude/analytics-client-common';
import { BeforePlugin, BrowserClient } from '@amplitude/analytics-types';
import { BrowserConfig } from 'src/config';

interface EventListener {
type: 'online' | 'offline';
handler: () => void;
}

export const networkConnectivityCheckerPlugin = (): BeforePlugin => {
const name = '@amplitude/plugin-network-checker-browser';
const type = 'before' as const;
const globalScope = getGlobalScope();
let eventListeners: EventListener[] = [];

const addNetworkListener = (type: 'online' | 'offline', handler: () => void) => {
if (globalScope) {
globalScope.addEventListener(type, handler);
eventListeners.push({
type,
handler,
});
}
};

const removeNetworkListeners = () => {
eventListeners.forEach(({ type, handler }) => {
if (globalScope) {
globalScope.removeEventListener(type, handler);
}
});
eventListeners = [];
};

const setup = async (config: BrowserConfig, amplitude: BrowserClient) => {
config.offline = !navigator.onLine;

addNetworkListener('online', () => {
config.loggerProvider.debug('Network connectivity changed to online.');
config.offline = false;
// Flush immediately will cause ERR_NETWORK_CHANGED
setTimeout(() => {
amplitude.flush();
}, config.flushIntervalMillis);
});

addNetworkListener('offline', () => {
config.loggerProvider.debug('Network connectivity changed to offline.');
config.offline = true;
});
};

const teardown = async () => {
removeNetworkListeners();
};

return {
name,
type,
setup,
teardown,
};
};
103 changes: 102 additions & 1 deletion packages/analytics-browser/test/browser-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,19 @@ import { AmplitudeBrowser } from '../src/browser-client';
import * as core from '@amplitude/analytics-core';
import * as Config from '../src/config';
import * as CookieMigration from '../src/cookie-migration';
import { UserSession } from '@amplitude/analytics-types';
import { OfflineDisabled, UserSession } from '@amplitude/analytics-types';
import {
CookieStorage,
FetchTransport,
getAnalyticsConnector,
getCookieName,
} from '@amplitude/analytics-client-common';
import * as SnippetHelper from '../src/utils/snippet-helper';
import * as AnalyticsClientCommon from '@amplitude/analytics-client-common';
import * as fileDownloadTracking from '../src/plugins/file-download-tracking';
import * as formInteractionTracking from '../src/plugins/form-interaction-tracking';
import * as webAttributionPlugin from '@amplitude/plugin-web-attribution-browser';
import * as networkConnectivityChecker from '../src/plugins/network-connectivity-checker';

describe('browser-client', () => {
let apiKey = '';
Expand Down Expand Up @@ -261,6 +263,105 @@ describe('browser-client', () => {
}).promise;
expect(webAttributionPluginPlugin).toHaveBeenCalledTimes(1);
});

test('should add network connectivity checker plugin by default', async () => {
const networkConnectivityCheckerPlugin = jest.spyOn(
networkConnectivityChecker,
'networkConnectivityCheckerPlugin',
);
await client.init(apiKey, userId).promise;
expect(networkConnectivityCheckerPlugin).toHaveBeenCalledTimes(1);
});

test('should not add network connectivity checker plugin if offline is disabled', async () => {
const networkConnectivityCheckerPlugin = jest.spyOn(
networkConnectivityChecker,
'networkConnectivityCheckerPlugin',
);
await client.init(apiKey, userId, {
offline: OfflineDisabled,
}).promise;
expect(networkConnectivityCheckerPlugin).toHaveBeenCalledTimes(0);
});

test('should listen for network change to online', async () => {
jest.useFakeTimers();
const addEventListenerMock = jest.spyOn(window, 'addEventListener');
const flush = jest.spyOn(client, 'flush').mockReturnValue({ promise: Promise.resolve() });
const loggerProvider = {
log: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
enable: jest.fn(),
disable: jest.fn(),
};

await client.init(apiKey, {
defaultTracking: false,
loggerProvider: loggerProvider,
}).promise;
window.dispatchEvent(new Event('online'));

expect(addEventListenerMock).toHaveBeenCalledWith('online', expect.any(Function));
expect(client.config.offline).toBe(false);
expect(loggerProvider.debug).toHaveBeenCalledTimes(1);
expect(loggerProvider.debug).toHaveBeenCalledWith('Network connectivity changed to online.');

jest.advanceTimersByTime(client.config.flushIntervalMillis);
expect(flush).toHaveBeenCalledTimes(1);

jest.useRealTimers();
addEventListenerMock.mockRestore();
flush.mockRestore();
});

test('should listen for network change to offline', async () => {
jest.useFakeTimers();
const addEventListenerMock = jest.spyOn(window, 'addEventListener');
const loggerProvider = {
log: jest.fn(),
debug: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
enable: jest.fn(),
disable: jest.fn(),
};

await client.init(apiKey, {
defaultTracking: false,
loggerProvider: loggerProvider,
}).promise;
expect(client.config.offline).toBe(false);

window.dispatchEvent(new Event('offline'));
expect(addEventListenerMock).toHaveBeenCalledWith('offline', expect.any(Function));
expect(client.config.offline).toBe(true);
expect(loggerProvider.debug).toHaveBeenCalledTimes(1);
expect(loggerProvider.debug).toHaveBeenCalledWith('Network connectivity changed to offline.');

jest.useRealTimers();
addEventListenerMock.mockRestore();
});

test('should not support offline mode if global scope returns undefined', async () => {
const getGlobalScopeMock = jest.spyOn(AnalyticsClientCommon, 'getGlobalScope').mockReturnValueOnce(undefined);
const addEventListenerMock = jest.spyOn(window, 'addEventListener');

await client.init(apiKey, {
defaultTracking: false,
}).promise;

window.dispatchEvent(new Event('online'));
expect(client.config.offline).toBe(false);

client.config.offline = true;
window.dispatchEvent(new Event('offline'));
expect(client.config.offline).toBe(true);

getGlobalScopeMock.mockRestore();
addEventListenerMock.mockRestore();
});
});

describe('getUserId', () => {
Expand Down
4 changes: 4 additions & 0 deletions packages/analytics-browser/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('config', () => {
loggerProvider: logger,
logLevel: LogLevel.Warn,
minIdLength: undefined,
offline: false,
partnerId: undefined,
plan: undefined,
ingestionMetadata: undefined,
Expand Down Expand Up @@ -113,6 +114,7 @@ describe('config', () => {
loggerProvider: logger,
logLevel: LogLevel.Warn,
minIdLength: undefined,
offline: false,
partnerId: undefined,
plan: undefined,
ingestionMetadata: undefined,
Expand Down Expand Up @@ -165,6 +167,7 @@ describe('config', () => {
upgrade: false,
},
defaultTracking: true,
offline: true,
},
new AmplitudeBrowser(),
);
Expand Down Expand Up @@ -197,6 +200,7 @@ describe('config', () => {
logLevel: 2,
loggerProvider: logger,
minIdLength: undefined,
offline: true,
partnerId: 'partnerId',
plan: {
version: '0',
Expand Down
1 change: 1 addition & 0 deletions packages/analytics-browser/test/helpers/mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export const createConfigurationMock = (options?: Partial<BrowserConfig>) => {
logLevel: LogLevel.Warn,
loggerProvider: new Logger(),
minIdLength: undefined,
offline: false,
optOut: false,
plan: undefined,
ingestionMetadata: undefined,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/* eslint-disable @typescript-eslint/unbound-method */

import { createAmplitudeMock, createConfigurationMock } from '../helpers/mock';
import { networkConnectivityCheckerPlugin } from '../../src/plugins/network-connectivity-checker';

describe('networkConnectivityCheckerPlugin', () => {
const amplitude = createAmplitudeMock();
const config = createConfigurationMock();

test('should set up correctly when online', async () => {
const plugin = networkConnectivityCheckerPlugin();
jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(true);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');

await plugin.setup?.(config, amplitude);

expect(config.offline).toEqual(false);
expect(addEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function));
expect(addEventListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function));
addEventListenerSpy.mockRestore();
});

test('should set up correctly when offline', async () => {
const plugin = networkConnectivityCheckerPlugin();
jest.spyOn(navigator, 'onLine', 'get').mockReturnValue(false);
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');

await plugin.setup?.(config, amplitude);

expect(config.offline).toEqual(true);
expect(addEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function));
expect(addEventListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function));
addEventListenerSpy.mockRestore();
});

test('should teardown plugin', async () => {
const plugin = networkConnectivityCheckerPlugin();
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');

await plugin.setup?.(createConfigurationMock(), amplitude);
await plugin.teardown?.();

expect(removeEventListenerSpy).toHaveBeenCalledWith('online', expect.any(Function));
expect(removeEventListenerSpy).toHaveBeenCalledWith('offline', expect.any(Function));
});
});
4 changes: 4 additions & 0 deletions packages/analytics-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
IngestionMetadata,
Options,
ServerZoneType,
OfflineDisabled,
} from '@amplitude/analytics-types';
import {
AMPLITUDE_SERVER_URL,
Expand All @@ -26,6 +27,7 @@ export const getDefaultConfig = () => ({
instanceName: '$default_instance',
logLevel: LogLevel.Warn,
loggerProvider: new Logger(),
offline: false,
optOut: false,
serverUrl: AMPLITUDE_SERVER_URL,
serverZone: 'US' as ServerZoneType,
Expand All @@ -41,6 +43,7 @@ export class Config implements IConfig {
loggerProvider: ILogger;
logLevel: LogLevel;
minIdLength?: number;
offline?: boolean | typeof OfflineDisabled;
plan?: Plan;
ingestionMetadata?: IngestionMetadata;
serverUrl: string | undefined;
Expand Down Expand Up @@ -69,6 +72,7 @@ export class Config implements IConfig {
this.minIdLength = options.minIdLength;
this.plan = options.plan;
this.ingestionMetadata = options.ingestionMetadata;
this.offline = options.offline !== undefined ? options.offline : defaultConfig.offline;
this.optOut = options.optOut ?? defaultConfig.optOut;
this.serverUrl = options.serverUrl;
this.serverZone = options.serverZone || defaultConfig.serverZone;
Expand Down
11 changes: 10 additions & 1 deletion packages/analytics-core/src/plugins/destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ export class Destination implements DestinationPlugin {
}

schedule(timeout: number) {
if (this.scheduled) return;
if (this.scheduled || this.config.offline) {
return;
}

this.scheduled = setTimeout(() => {
void this.flush(true).then(() => {
if (this.queue.length > 0) {
Expand All @@ -117,6 +120,12 @@ export class Destination implements DestinationPlugin {
}

async flush(useRetry = false) {
// Skip flush if offline
if (this.config.offline) {
this.config.loggerProvider.debug('Skipping flush while offline.');
return;
}

const list: Context[] = [];
const later: Context[] = [];
this.queue.forEach((context) => (context.timeout === 0 ? list.push(context) : later.push(context)));
Expand Down
Loading

0 comments on commit f2cd717

Please sign in to comment.