Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add offline mode #644

Merged
merged 11 commits into from
Jan 24, 2024
4 changes: 4 additions & 0 deletions packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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 +87,9 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {

// Step 4: Install plugins
// Do not track any events before this
if (this.config.offline !== null) {
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
2 changes: 2 additions & 0 deletions packages/analytics-browser/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export class BrowserConfig extends Config implements IBrowserConfig {
public loggerProvider: ILogger = new Logger(),
public logLevel: LogLevel = LogLevel.Warn,
public minIdLength?: number,
public offline: boolean | null = false,
optOut = false,
public partnerId?: string,
public plan?: Plan,
Expand Down Expand Up @@ -236,6 +237,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,61 @@
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.offline = false;
// Flush immediately will cause ERR_NETWORK_CHANGED
setTimeout(() => {
amplitude.flush();
}, config.flushIntervalMillis);
justin-fiedler marked this conversation as resolved.
Show resolved Hide resolved
});

addNetworkListener('offline', () => {
config.offline = true;
});
};

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

return {
name,
type,
setup,
teardown,
};
};
79 changes: 79 additions & 0 deletions packages/analytics-browser/test/browser-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
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,83 @@ 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 null', async () => {
const networkConnectivityCheckerPlugin = jest.spyOn(
networkConnectivityChecker,
'networkConnectivityCheckerPlugin',
);
await client.init(apiKey, userId, {
offline: null,
}).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() });

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

expect(addEventListenerMock).toHaveBeenCalledWith('online', expect.any(Function));
expect(client.config.offline).toBe(false);

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');

await client.init(apiKey, {
defaultTracking: false,
}).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);

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));
});
});
3 changes: 3 additions & 0 deletions packages/analytics-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,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 +42,7 @@ export class Config implements IConfig {
loggerProvider: ILogger;
logLevel: LogLevel;
minIdLength?: number;
offline?: boolean | null;
plan?: Plan;
ingestionMetadata?: IngestionMetadata;
serverUrl: string | undefined;
Expand Down Expand Up @@ -69,6 +71,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
10 changes: 9 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,11 @@ export class Destination implements DestinationPlugin {
}

async flush(useRetry = false) {
// Skip flush if offline
if (this.config.offline) {
return;
justin-fiedler marked this conversation as resolved.
Show resolved Hide resolved
}

const list: Context[] = [];
const later: Context[] = [];
this.queue.forEach((context) => (context.timeout === 0 ? list.push(context) : later.push(context)));
Expand Down
3 changes: 3 additions & 0 deletions packages/analytics-core/test/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ describe('config', () => {
logLevel: LogLevel.Warn,
loggerProvider: new Logger(),
minIdLength: undefined,
offline: false,
_optOut: false, // private for `optOut` getter/setter
partnerId: undefined,
plan: undefined,
Expand All @@ -44,6 +45,7 @@ describe('config', () => {
const config = new Config({
apiKey: API_KEY,
logLevel: LogLevel.Verbose,
offline: true,
optOut: true,
plan: { version: '0' },
ingestionMetadata: {
Expand All @@ -63,6 +65,7 @@ describe('config', () => {
logLevel: LogLevel.Verbose,
loggerProvider: new Logger(),
minIdLength: undefined,
offline: true,
_optOut: true,
plan: {
version: '0',
Expand Down
Loading