Skip to content

Commit

Permalink
feat(analytics-browser): consume remote config (#769)
Browse files Browse the repository at this point in the history
This reverts commit d00981e.
  • Loading branch information
Mercy811 committed Jul 30, 2024
1 parent 6635553 commit f806782
Show file tree
Hide file tree
Showing 19 changed files with 353 additions and 9 deletions.
2 changes: 2 additions & 0 deletions jest.setup.examples.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// Mock [IndexedDB API](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) as jsdom doesn't support it
require('fake-indexeddb/auto');
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"lint:staged": "lint-staged",
"postinstall": "husky install",
"test": "lerna run test --stream",
"test:examples": "jest --env=jsdom --coverage=false examples",
"test:examples": "jest --env=jsdom --coverage=false examples --setupFiles ./jest.setup.examples.js",
"test:unit": "lerna run test --stream --ignore @amplitude/analytics-*-test",
"test:e2e": "lerna run test --stream --scope @amplitude/analytics-*-test",
"version": "git add -A"
Expand All @@ -32,6 +32,7 @@
"eslint": "^8.29.0",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-jest": "^27.1.6",
"fake-indexeddb": "4.0.2",
"husky": "^8.0.2",
"jest": "^29.3.1",
"jest-environment-jsdom": "^29.3.1",
Expand Down
1 change: 1 addition & 0 deletions packages/analytics-browser-test/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ module.exports = {
displayName: package.name,
rootDir: '.',
testEnvironment: 'jsdom',
setupFilesAfterEnv: ['./jest.setup.js'],
};
16 changes: 16 additions & 0 deletions packages/analytics-browser-test/jest.setup.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
jest.mock('@amplitude/analytics-remote-config', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const originalModule = jest.requireActual('@amplitude/analytics-browser');

// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return {
...originalModule,
createRemoteConfigFetch: jest.fn().mockImplementation(() => ({
getRemoteConfig: jest.fn().mockImplementation(() => {
// Mock it as no remote config is set
// Thus return an empty object
return Promise.resolve({});
})
}))
}
});
10 changes: 6 additions & 4 deletions packages/analytics-browser-test/test/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1944,9 +1944,10 @@ describe('integration', () => {
expect(response.message).toBe(SUCCESS_MESSAGE);
scope.done();

expect(logger.debug).toHaveBeenCalledTimes(3);
expect(logger.debug).toHaveBeenCalledTimes(6);
// 3 debug calls for getting and merging remote config in joined-config.ts
/* eslint-disable */
const debugContext = JSON.parse(logger.debug.mock.calls[2]);
const debugContext = JSON.parse(logger.debug.mock.calls[5]);
expect(debugContext.type).toBeDefined();
expect(debugContext.name).toEqual('track');
expect(debugContext.args).toBeDefined();
Expand All @@ -1972,9 +1973,10 @@ describe('integration', () => {
}).promise;
client.setOptOut(true);

expect(logger.debug).toHaveBeenCalledTimes(3);
expect(logger.debug).toHaveBeenCalledTimes(6);
// 3 debug calls for getting and merging remote config in joined-config.ts
/* eslint-disable */
const debugContext = JSON.parse(logger.debug.mock.calls[2]);
const debugContext = JSON.parse(logger.debug.mock.calls[5]);
expect(debugContext.type).toBeDefined();
expect(debugContext.name).toEqual('setOptOut');
expect(debugContext.args).toBeDefined();
Expand Down
3 changes: 3 additions & 0 deletions packages/analytics-browser/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,9 @@
"dependencies": {
"@amplitude/analytics-client-common": "^2.2.4",
"@amplitude/analytics-core": "^2.3.0",
"@amplitude/analytics-remote-config": "^0.2.1",
"@amplitude/analytics-types": "^2.6.0",
"@amplitude/plugin-autocapture-browser": "^0.9.2",
"@amplitude/plugin-page-view-tracking-browser": "^2.2.17",
"tslib": "^2.4.1"
},
Expand All @@ -56,6 +58,7 @@
"@rollup/plugin-commonjs": "^23.0.4",
"@rollup/plugin-node-resolve": "^15.0.1",
"@rollup/plugin-typescript": "^10.0.1",
"fake-indexeddb": "4.0.2",
"http-server": "^14.1.1",
"isomorphic-fetch": "^3.0.0",
"rollup": "^2.79.1",
Expand Down
7 changes: 5 additions & 2 deletions packages/analytics-browser/src/browser-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ 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';
import { createBrowserJoinedConfigGenerator } from './config/joined-config';

export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
Expand Down Expand Up @@ -72,8 +73,10 @@ export class AmplitudeBrowser extends AmplitudeCore implements BrowserClient {

// Step 2: Create browser config
const browserOptions = await useBrowserConfig(options.apiKey, options, this);
await super._init(browserOptions);
this.logBrowserOptions(options);
const joinedConfigGenerator = await createBrowserJoinedConfigGenerator(browserOptions);
const joinedConfig = await joinedConfigGenerator.generateJoinedConfig();
await super._init(joinedConfig);
this.logBrowserOptions(joinedConfig);

// Add web attribution plugin
if (isAttributionTrackingEnabled(this.config.defaultTracking)) {
Expand Down
42 changes: 42 additions & 0 deletions packages/analytics-browser/src/config/joined-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { BrowserConfig as IBrowserConfig } from '@amplitude/analytics-types';
import { createRemoteConfigFetch, RemoteConfigFetch } from '@amplitude/analytics-remote-config';
import { BrowserRemoteConfig } from './types';

export class BrowserJoinedConfigGenerator {
// Local config before generateJoinedConfig is called
// Joined config after generateJoinedConfig is called
config: IBrowserConfig;
remoteConfigFetch: RemoteConfigFetch<BrowserRemoteConfig> | undefined;

constructor(localConfig: IBrowserConfig) {
this.config = localConfig;
this.config.loggerProvider.debug(
'Local configuration before merging with remote config',
JSON.stringify(this.config, null, 2),
);
}
async initialize() {
this.remoteConfigFetch = await createRemoteConfigFetch<BrowserRemoteConfig>({
localConfig: this.config,
configKeys: ['analyticsSDK'],
});
}

async generateJoinedConfig(): Promise<IBrowserConfig> {
const remoteConfig =
this.remoteConfigFetch &&
(await this.remoteConfigFetch.getRemoteConfig('analyticsSDK', 'browserSDK', this.config.sessionId));
this.config.loggerProvider.debug('Remote configuration:', JSON.stringify(remoteConfig, null, 2));
if (remoteConfig && remoteConfig.defaultTracking) {
this.config.defaultTracking = remoteConfig.defaultTracking;
}
this.config.loggerProvider.debug('Joined configuration: ', JSON.stringify(remoteConfig, null, 2));
return this.config;
}
}

export const createBrowserJoinedConfigGenerator = async (localConfig: IBrowserConfig) => {
const joinedConfigGenerator = new BrowserJoinedConfigGenerator(localConfig);
await joinedConfigGenerator.initialize();
return joinedConfigGenerator;
};
9 changes: 9 additions & 0 deletions packages/analytics-browser/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { DefaultTrackingOptions } from '@amplitude/analytics-types';
import { AutocaptureOptions } from '@amplitude/plugin-autocapture-browser';

export type BrowserRemoteConfig = {
browserSDK: {
autoCapture?: AutocaptureOptions;
defaultTracking?: DefaultTrackingOptions;
};
};
5 changes: 5 additions & 0 deletions packages/analytics-browser/test/browser-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ import * as fileDownloadTracking from '../src/plugins/file-download-tracking';
import * as formInteractionTracking from '../src/plugins/form-interaction-tracking';
import * as networkConnectivityChecker from '../src/plugins/network-connectivity-checker';
import * as SnippetHelper from '../src/utils/snippet-helper';
jest.mock('../src/config/joined-config', () => ({
createBrowserJoinedConfigGenerator: jest.fn().mockImplementation((localConfig) => ({
generateJoinedConfig: jest.fn().mockResolvedValue(localConfig),
})),
}));

describe('browser-client', () => {
let apiKey = '';
Expand Down
93 changes: 93 additions & 0 deletions packages/analytics-browser/test/config/joined-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { createRemoteConfigFetch, RemoteConfigFetch } from '@amplitude/analytics-remote-config';
import { BrowserConfig as IBrowserConfig } from '@amplitude/analytics-types';
import { BrowserJoinedConfigGenerator, createBrowserJoinedConfigGenerator } from '../../src/config/joined-config';
import { createConfigurationMock } from '../helpers/mock';
import { BrowserRemoteConfig } from '../../lib/scripts/config/types';

jest.mock('@amplitude/analytics-remote-config', () => ({
createRemoteConfigFetch: jest.fn(),
}));

describe('joined-config', () => {
let localConfig: IBrowserConfig;
let mockRemoteConfigFetch: RemoteConfigFetch<BrowserRemoteConfig>;
let generator: BrowserJoinedConfigGenerator;

beforeEach(() => {
localConfig = { ...createConfigurationMock(), defaultTracking: false };

mockRemoteConfigFetch = {
getRemoteConfig: jest.fn().mockResolvedValue({
defaultTracking: true,
}),
// TODO(xinyi): uncomment this line when fetchTime is used in the joined config
// fetchTime: 23,
};

// Mock the createRemoteConfigFetch to return the mockRemoteConfigFetch
(createRemoteConfigFetch as jest.MockedFunction<typeof createRemoteConfigFetch>).mockResolvedValue(
mockRemoteConfigFetch,
);

generator = new BrowserJoinedConfigGenerator(localConfig);
});

afterEach(() => {
jest.clearAllMocks();
});

describe('BrowserJoinedConfigGenerator', () => {
describe('constructor', () => {
test('should set localConfig', () => {
expect(generator.config).toEqual(localConfig);
expect(generator.remoteConfigFetch).toBeUndefined();
});
});

describe('initialize', () => {
test('should set remoteConfigFetch', async () => {
await generator.initialize();

expect(generator.remoteConfigFetch).not.toBeUndefined();
expect(createRemoteConfigFetch).toHaveBeenCalledWith({
localConfig,
configKeys: ['analyticsSDK'],
});
expect(generator.remoteConfigFetch).toBe(mockRemoteConfigFetch);
});
});

describe('generateJoinedConfig', () => {
test('should merge local and remote config', async () => {
await generator.initialize();
expect(generator.config.defaultTracking).toBe(false);
const joinedConfig = await generator.generateJoinedConfig();
const expectedConfig = localConfig;
expectedConfig.defaultTracking = true;

expect(mockRemoteConfigFetch.getRemoteConfig).toHaveBeenCalledWith(
'analyticsSDK',
'browserSDK',
localConfig.sessionId,
);
// expectedConfig also includes protected properties
expect(joinedConfig).toEqual(expectedConfig);
});

test('should use local config if remoteConfigFetch is not set', async () => {
expect(generator.remoteConfigFetch).toBeUndefined();
const joinedConfig = await generator.generateJoinedConfig();
expect(joinedConfig).toEqual(localConfig);
});
});
});

describe('createBrowserJoinedConfigGenerator', () => {
test('should create joined config generator', async () => {
const generator = await createBrowserJoinedConfigGenerator(localConfig);

expect(generator.config).toEqual(localConfig);
expect(generator.remoteConfigFetch).toBe(mockRemoteConfigFetch);
});
});
});
2 changes: 2 additions & 0 deletions packages/analytics-core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
Options,
ServerZoneType,
OfflineDisabled,
RequestMetadata,
} from '@amplitude/analytics-types';
import {
AMPLITUDE_SERVER_URL,
Expand Down Expand Up @@ -51,6 +52,7 @@ export class Config implements IConfig {
transportProvider: Transport;
storageProvider?: Storage<Event[]>;
useBatch: boolean;
request_metadata?: RequestMetadata;

protected _optOut = false;
get optOut() {
Expand Down
3 changes: 3 additions & 0 deletions packages/analytics-core/src/plugins/destination.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ export class Destination implements DestinationPlugin {
return this.fulfillRequest(list, 400, MISSING_API_KEY_MESSAGE);
}

const request_metadata = this.config.request_metadata;
delete this.config.request_metadata;
const payload = {
api_key: this.config.apiKey,
events: list.map((context) => {
Expand All @@ -172,6 +174,7 @@ export class Destination implements DestinationPlugin {
min_id_length: this.config.minIdLength,
},
client_upload_time: new Date().toISOString(),
request_metadata: request_metadata,
};

try {
Expand Down
52 changes: 52 additions & 0 deletions packages/analytics-core/test/plugins/destination.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,58 @@ describe('destination', () => {
});
});

test('should include request metadata', async () => {
const destination = new Destination();
const callback = jest.fn();
const event = {
event_type: 'event_type',
};
const context = {
attempts: 0,
callback,
event,
timeout: 0,
};
const request_metadata = {
sdk: {
metrics: {
histogram: {
remote_config_fetch_time: 0,
},
},
},
};

const transportProvider = {
send: jest.fn().mockImplementationOnce((_url: string, payload: Payload) => {
expect(payload.request_metadata).toBe(request_metadata);
return Promise.resolve({
status: Status.Success,
statusCode: 200,
body: {
eventsIngested: 1,
payloadSizeBytes: 1,
serverUploadTime: 1,
},
});
}),
};
await destination.setup({
...useDefaultConfig(),
transportProvider,
apiKey: API_KEY,
request_metadata: request_metadata,
});
await destination.send([context]);
expect(destination.config.request_metadata).toBeUndefined();
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith({
event,
code: 200,
message: SUCCESS_MESSAGE,
});
});

test('should include min id length', async () => {
const destination = new Destination();
const callback = jest.fn();
Expand Down
14 changes: 14 additions & 0 deletions packages/analytics-types/src/config/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,20 @@ export interface Config {
* The flag of whether to upload events to Batch API instead of the default HTTP V2 API.
*/
useBatch: boolean;
/**
* Metrics of the SDK.
*/
request_metadata?: RequestMetadata;
}

export interface RequestMetadata {
sdk: {
metrics: {
histogram: {
remote_config_fetch_time: number;
};
};
};
}

export interface Options extends Partial<Config> {
Expand Down
2 changes: 1 addition & 1 deletion packages/analytics-types/src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export { Config, Options } from './core';
export { Config, Options, RequestMetadata } from './core';
export { BrowserConfig, DefaultTrackingOptions, TrackingOptions, AttributionOptions, BrowserOptions } from './browser';
export { NodeConfig, NodeOptions } from './node';
export {
Expand Down
1 change: 1 addition & 0 deletions packages/analytics-types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export {
ReactNativeOptions,
ReactNativeTrackingOptions,
TrackingOptions,
RequestMetadata,
} from './config';
export { CoreClient } from './client/core-client';
export { DestinationContext } from './destination-context';
Expand Down
Loading

0 comments on commit f806782

Please sign in to comment.