diff --git a/packages/plugin-custom-enrichment-browser/CHANGELOG.md b/packages/plugin-custom-enrichment-browser/CHANGELOG.md new file mode 100644 index 000000000..fa4d35e68 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/CHANGELOG.md @@ -0,0 +1 @@ +# Change Log \ No newline at end of file diff --git a/packages/plugin-custom-enrichment-browser/README.md b/packages/plugin-custom-enrichment-browser/README.md new file mode 100644 index 000000000..31eb824ef --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/README.md @@ -0,0 +1,63 @@ +

+ + + +
+

+ +# @amplitude/plugin-custom-enrichment-browser + +Official Browser SDK plugin for custom enrichment + +## Installation + +This package is published on NPM registry and is available to be installed using npm and yarn. + +```sh +# npm +npm install @amplitude/plugin-custom-enrichment-browser + +# yarn +yarn add @amplitude/plugin-custom-enrichment-browser +``` + +## Usage + +This plugin works on top of Amplitude Browser SDK and allows the user to execute custom functionality on their events. To use this plugin, you need to install `@amplitude/analytics-browser` version `v2.0.0` or later. + +### 1. Import Amplitude packages + +* `@amplitude/plugin-custom-enrichment-browser` + +```typescript +import { customEnrichmentPlugin } from '@amplitude/plugin-custom-enrichment-browser'; +``` + +### 2. Instantiate custom enrichment plugin +```typescript +const customEnrichmentPlugin = customEnrichmentPlugin(); +``` + +#### Options + + +### 3. Install plugin to Amplitude SDK + +```typescript +amplitude.add(customEnrichmentPlugin); +``` + +### 4. Initialize Amplitude SDK + +```typescript +amplitude.init('API_KEY'); +``` + +## Result +This plugin executes a user-defined script, defined within Amplitude Remote Configuration Settings. + +#### Event type +* No event type added + +#### Event properties +* Defined by user \ No newline at end of file diff --git a/packages/plugin-custom-enrichment-browser/jest.config.js b/packages/plugin-custom-enrichment-browser/jest.config.js new file mode 100644 index 000000000..dc4094b18 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/jest.config.js @@ -0,0 +1,10 @@ +const baseConfig = require('../../jest.config.js'); +const package = require('./package'); + +module.exports = { + ...baseConfig, + displayName: package.name, + rootDir: '.', + testEnvironment: 'jsdom', + coveragePathIgnorePatterns: ['index.ts'], +}; diff --git a/packages/plugin-custom-enrichment-browser/package.json b/packages/plugin-custom-enrichment-browser/package.json new file mode 100644 index 000000000..81536df59 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/package.json @@ -0,0 +1,51 @@ +{ + "name": "@amplitude/plugin-custom-enrichment-browser", + "private": true, + "version": "0.0.1", + "description": "", + "author": "Amplitude Inc", + "homepage": "https://github.com/amplitude/Amplitude-TypeScript", + "license": "MIT", + "main": "lib/cjs/index.js", + "module": "lib/esm/index.js", + "types": "lib/esm/index.d.ts", + "sideEffects": false, + "publishConfig": { + "access": "public", + "tag": "latest" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/amplitude/Amplitude-TypeScript.git" + }, + "scripts": { + "build": "yarn bundle && yarn build:es5 && yarn build:esm", + "bundle": "rollup --config rollup.config.js", + "build:es5": "tsc -p ./tsconfig.es5.json", + "build:esm": "tsc -p ./tsconfig.esm.json", + "watch": "tsc -p ./tsconfig.esm.json --watch", + "clean": "rimraf node_modules lib coverage", + "fix": "yarn fix:eslint & yarn fix:prettier", + "fix:eslint": "eslint '{src,test}/**/*.ts' --fix", + "fix:prettier": "prettier --write \"{src,test}/**/*.ts\"", + "lint": "yarn lint:eslint & yarn lint:prettier", + "lint:eslint": "eslint '{src,test}/**/*.ts'", + "lint:prettier": "prettier --check \"{src,test}/**/*.ts\"", + "test": "jest", + "typecheck": "tsc -p ./tsconfig.json" + }, + "bugs": { + "url": "https://github.com/amplitude/Amplitude-TypeScript/issues" + }, + "dependencies": { + "@amplitude/analytics-core": "^2.27.0", + "tslib": "^2.4.1" + + }, + "devDependencies": { + + }, + "files": [ + "lib" + ] +} diff --git a/packages/plugin-custom-enrichment-browser/rollup.config.js b/packages/plugin-custom-enrichment-browser/rollup.config.js new file mode 100644 index 000000000..2718b91d7 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/rollup.config.js @@ -0,0 +1,3 @@ +import { umd } from '../../scripts/build/rollup.config'; + +export default [umd]; diff --git a/packages/plugin-custom-enrichment-browser/src/custom-enrichment.ts b/packages/plugin-custom-enrichment-browser/src/custom-enrichment.ts new file mode 100644 index 000000000..7d6fb7fa2 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/src/custom-enrichment.ts @@ -0,0 +1,53 @@ +import type { BrowserClient, BrowserConfig, EnrichmentPlugin, Event, ILogger } from '@amplitude/analytics-core'; + +export const customEnrichmentPlugin = (): EnrichmentPlugin => { + let loggerProvider: ILogger | undefined = undefined; + let customEnrichmentBody: string | undefined = undefined; + + const plugin: EnrichmentPlugin = { + name: '@amplitude/plugin-custom-enrichment-browser', + type: 'enrichment', + + setup: async (config: BrowserConfig, _: BrowserClient) => { + loggerProvider = config.loggerProvider; + loggerProvider?.log('Installing @amplitude/plugin-custom-enrichment-browser'); + + // Fetch remote config for custom enrichment in a non-blocking manner + if (config.fetchRemoteConfig) { + if (!config.remoteConfigClient) { + // TODO(xinyi): Diagnostics.recordEvent + config.loggerProvider.debug('Remote config client is not provided, skipping remote config fetch'); + } else { + config.remoteConfigClient.subscribe('analyticsSDK.customEnrichment', 'all', (remoteConfig) => { + if (remoteConfig) { + customEnrichmentBody = remoteConfig.body as string | undefined; + } + }); + } + } + }, + execute: async (event: Event) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-implied-eval + const enrichEvent = new Function('event', customEnrichmentBody || '') as (event: Event) => Event; + + const enrichedEvent: Event = enrichEvent(event); + + if (!enrichedEvent) { + return event; + } + + return enrichedEvent; + } catch (error) { + loggerProvider?.error('Could not execute custom enrichment function', error); + } + + return event; + }, + teardown: async () => { + // No teardown required + }, + }; + + return plugin; +}; diff --git a/packages/plugin-custom-enrichment-browser/src/index.ts b/packages/plugin-custom-enrichment-browser/src/index.ts new file mode 100644 index 000000000..0eb16820f --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/src/index.ts @@ -0,0 +1,2 @@ +export { customEnrichmentPlugin } from './custom-enrichment'; +export { customEnrichmentPlugin as plugin } from './custom-enrichment'; diff --git a/packages/plugin-custom-enrichment-browser/test/custom-enrichment.test.ts b/packages/plugin-custom-enrichment-browser/test/custom-enrichment.test.ts new file mode 100644 index 000000000..1a3f8a738 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/test/custom-enrichment.test.ts @@ -0,0 +1,462 @@ +import { type BrowserClient, type BrowserConfig, type ILogger, Logger, LogLevel } from '@amplitude/analytics-core'; +import { customEnrichmentPlugin } from '../src/custom-enrichment'; + +// Mock BrowserClient implementation +const createMockBrowserClient = (): jest.Mocked => { + const mockClient = { + init: jest.fn().mockReturnValue({ + promise: Promise.resolve(), + }), + add: jest.fn(), + remove: jest.fn(), + track: jest.fn(), + logEvent: jest.fn(), + identify: jest.fn(), + groupIdentify: jest.fn(), + setGroup: jest.fn(), + revenue: jest.fn(), + flush: jest.fn(), + getUserId: jest.fn(), + setUserId: jest.fn(), + getDeviceId: jest.fn(), + setDeviceId: jest.fn(), + getSessionId: jest.fn(), + setSessionId: jest.fn(), + extendSession: jest.fn(), + reset: jest.fn(), + setOptOut: jest.fn(), + setTransport: jest.fn(), + } as unknown as jest.Mocked; + + mockClient.track.mockReturnValue({ + promise: Promise.resolve({ + code: 200, + message: '', + event: { + event_type: 'test-event', + }, + }), + }); + + return mockClient; +}; + +const createMockConfig = (): BrowserConfig => ({ + apiKey: 'test-api-key', + flushQueueSize: 10, + flushIntervalMillis: 1000, + logLevel: LogLevel.Verbose, + loggerProvider: new Logger(), + sessionTimeout: 30000, + flushMaxRetries: 5, + optOut: false, + useBatch: false, + fetchRemoteConfig: false, + trackingOptions: { + ipAddress: true, + language: true, + platform: true, + }, + cookieStorage: { + isEnabled: jest.fn().mockReturnValue(true), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + reset: jest.fn(), + getRaw: jest.fn(), + }, + storageProvider: { + isEnabled: jest.fn().mockReturnValue(true), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + reset: jest.fn(), + getRaw: jest.fn(), + }, + transportProvider: { + send: jest.fn(), + }, +}); + +describe('Custom Enrichment Plugin', () => { + let mockClient: jest.Mocked; + let mockConfig: BrowserConfig; + let mockLogger: ILogger; + + beforeEach(() => { + jest.clearAllMocks(); + mockClient = createMockBrowserClient(); + mockConfig = createMockConfig(); + mockLogger = { + log: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + logLevel: LogLevel.Verbose, + disable: jest.fn(), + enable: jest.fn(), + } as ILogger; + mockConfig.loggerProvider = mockLogger; + }); + describe('execute', () => { + it('should execute custom enrichment function successfully', async () => { + const customFunction = ` + event.event_properties = event.event_properties || {}; + event.event_properties.custom_field = 'enriched_value'; + return event; + `; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the custom function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: customFunction }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { + event_type: 'test_event', + event_properties: { original: 'value' }, + }; + + const result = await plugin.execute?.(originalEvent); + + expect(result).toEqual({ + event_type: 'test_event', + event_properties: { + original: 'value', + custom_field: 'enriched_value', + }, + }); + }); + + it('should handle enrichment function that adds or modifies the event', async () => { + const timestamp = Date.now(); + const customFunction = ` + event.user_properties = { custom_user_prop: 'user_value' }; + event.event_properties = { ...event.event_properties, timestamp: ${timestamp} }; + event.event_type = 'enriched_event'; + return event; + `; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the custom function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: customFunction }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { + event_type: 'test_event', + event_properties: { test: 'value' }, + }; + + const result = await plugin.execute?.(originalEvent); + + expect(result?.event_type).toBe('enriched_event'); + expect(result?.event_properties).toStrictEqual({ test: 'value', timestamp: timestamp }); + expect(result?.user_properties).toStrictEqual({ custom_user_prop: 'user_value' }); + }); + + it('should return original event if enrichment function throws error', async () => { + const invalidFunction = ` + throw new Error('Invalid enrichment function'); + `; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the invalid function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: invalidFunction }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + expect(result).toEqual(originalEvent); + expect((mockLogger.error as jest.Mock).mock.calls.length).toBe(1); + expect((mockLogger.error as jest.Mock).mock.calls[0][0]).toBe('Could not execute custom enrichment function'); + }); + + it('should handle undefined loggerProvider in execute error case', async () => { + const configWithoutLogger = { ...mockConfig, loggerProvider: undefined as unknown as Logger } as BrowserConfig; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the error function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: 'throw new Error("test error");' }); + } + }), + }; + + const configWithRemoteConfig = { + ...configWithoutLogger, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + // Should return original event even with undefined loggerProvider + expect(result).toEqual(originalEvent); + }); + + it('should return original event if enrichment function is invalid', async () => { + const invalidFunction = 'invalid javascript syntax {'; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the invalid function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: invalidFunction }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + expect(result).toEqual(originalEvent); + expect((mockLogger.error as jest.Mock).mock.calls.length).toBe(1); + expect((mockLogger.error as jest.Mock).mock.calls[0][0]).toBe('Could not execute custom enrichment function'); + }); + + it('should return original event if enrichment function does not return an event', async () => { + const customFunction = ` + // Function that doesn't return anything + event.event_properties = { modified: true }; + `; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the custom function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: customFunction }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + // Should return the original event since the function didn't return anything + expect(result).toEqual(originalEvent); + }); + + it('should handle empty enrichment function', async () => { + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide empty function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: '' }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + expect(result).toEqual(originalEvent); + }); + + it('should handle enrichment function with only comments', async () => { + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide comment-only function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: '// This is just a comment' }); + } + }), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + expect(result).toEqual(originalEvent); + }); + }); + + describe('remote config integration', () => { + it('should handle missing remote config client', async () => { + const plugin = customEnrichmentPlugin(); + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: undefined, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + // Should return original event when no remote config is available + expect(result).toEqual(originalEvent); + expect((mockLogger.debug as jest.Mock).mock.calls.length).toBe(1); + expect((mockLogger.debug as jest.Mock).mock.calls[0][0]).toBe( + 'Remote config client is not provided, skipping remote config fetch', + ); + }); + + it('should handle fetchRemoteConfig disabled', async () => { + const plugin = customEnrichmentPlugin(); + + const configWithoutRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: false, + }; + + await plugin.setup?.(configWithoutRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + // Should return original event when remote config is disabled + expect(result).toEqual(originalEvent); + }); + + it('should handle remote config subscription', async () => { + const plugin = customEnrichmentPlugin(); + + const mockRemoteConfigClient = { + subscribe: jest.fn(), + }; + + const configWithRemoteConfig = { + ...mockConfig, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + // Verify that subscribe was called with the correct parameters + expect(mockRemoteConfigClient.subscribe).toHaveBeenCalled(); + expect(mockRemoteConfigClient.subscribe.mock.calls[0][0]).toBe('analyticsSDK.customEnrichment'); + expect(mockRemoteConfigClient.subscribe.mock.calls[0][1]).toBe('all'); + expect(typeof mockRemoteConfigClient.subscribe.mock.calls[0][2]).toBe('function'); + }); + }); + + describe('teardown', () => { + it('should complete teardown without errors', async () => { + const plugin = customEnrichmentPlugin(); + await plugin.setup?.(mockConfig, mockClient); + await expect(plugin.teardown?.()).resolves.toBeUndefined(); + }); + }); + + describe('undefined loggerProvider', () => { + it('should handle undefined loggerProvider in setup', async () => { + const configWithoutLogger = { ...mockConfig, loggerProvider: undefined } as unknown as BrowserConfig; + const plugin = customEnrichmentPlugin(); + + // Should not throw an error even with undefined loggerProvider + await expect(plugin.setup?.(configWithoutLogger, mockClient)).resolves.toBeUndefined(); + }); + + it('should handle undefined loggerProvider in execute error case', async () => { + const configWithoutLogger = { ...mockConfig, loggerProvider: undefined } as unknown as BrowserConfig; + const plugin = customEnrichmentPlugin(); + + // Mock remote config to provide the error function + const mockRemoteConfigClient = { + subscribe: jest.fn((key, _audience, callback) => { + if (key === 'analyticsSDK.customEnrichment') { + callback({ body: 'throw new Error("test error");' }); + } + }), + }; + + const configWithRemoteConfig = { + ...configWithoutLogger, + fetchRemoteConfig: true, + remoteConfigClient: mockRemoteConfigClient, + }; + + await plugin.setup?.(configWithRemoteConfig, mockClient); + + const originalEvent = { event_type: 'test_event' }; + const result = await plugin.execute?.(originalEvent); + + // Should return original event and not throw even with undefined loggerProvider + expect(result).toEqual(originalEvent); + }); + }); +}); diff --git a/packages/plugin-custom-enrichment-browser/tsconfig.es5.json b/packages/plugin-custom-enrichment-browser/tsconfig.es5.json new file mode 100644 index 000000000..77e041d3f --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/tsconfig.es5.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "module": "commonjs", + "noEmit": false, + "outDir": "lib/cjs", + "rootDir": "./src" + } +} diff --git a/packages/plugin-custom-enrichment-browser/tsconfig.esm.json b/packages/plugin-custom-enrichment-browser/tsconfig.esm.json new file mode 100644 index 000000000..bec981eee --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/tsconfig.esm.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "include": ["src/**/*"], + "compilerOptions": { + "module": "es6", + "noEmit": false, + "outDir": "lib/esm", + "rootDir": "./src" + } +} diff --git a/packages/plugin-custom-enrichment-browser/tsconfig.json b/packages/plugin-custom-enrichment-browser/tsconfig.json new file mode 100644 index 000000000..955dcce78 --- /dev/null +++ b/packages/plugin-custom-enrichment-browser/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src/**/*", "test/**/*"], + "compilerOptions": { + "baseUrl": ".", + "esModuleInterop": true, + "lib": ["dom"], + "noEmit": true, + "rootDir": ".", + } +} diff --git a/packages/plugin-page-url-enrichment-browser/README.md b/packages/plugin-page-url-enrichment-browser/README.md index c67aac345..a43792ce2 100644 --- a/packages/plugin-page-url-enrichment-browser/README.md +++ b/packages/plugin-page-url-enrichment-browser/README.md @@ -34,9 +34,6 @@ import { pageUrlEnrichmentPlugin } from '@amplitude/plugin-page-url-enrichment-b ``` ### 2. Instantiate page url enrichment plugin - -The plugin accepts an optional parameter of type `Object` to configure the plugin based on your use case. - ```typescript const pageUrlEnrichment = pageUrlEnrichmentPlugin(); ```