├── .eslintignore ├── .prettierignore ├── examples ├── react-app │ ├── basic │ │ ├── src │ │ │ ├── react-app-env.d.ts │ │ │ ├── index.css │ │ │ ├── index.tsx │ │ │ ├── App.css │ │ │ ├── App.tsx │ │ │ └── logo.svg │ │ ├── public │ │ │ ├── robots.txt │ │ │ ├── favicon.ico │ │ │ ├── logo192.png │ │ │ ├── logo512.png │ │ │ ├── manifest.json │ │ │ └── index.html │ │ ├── .gitignore │ │ ├── tsconfig.json │ │ ├── package.json │ │ └── README.md │ └── amplitude-integration │ │ ├── src │ │ ├── react-app-env.d.ts │ │ ├── index.css │ │ ├── App.css │ │ ├── index.tsx │ │ ├── App.tsx │ │ └── logo.svg │ │ ├── public │ │ ├── robots.txt │ │ ├── favicon.ico │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── index.html │ │ ├── .gitignore │ │ ├── tsconfig.json │ │ ├── package.json │ │ └── README.md └── html-app │ ├── basic │ ├── package.json │ ├── README.md │ └── index.html │ └── amplitude-integration │ ├── package.json │ ├── README.md │ └── index.html ├── jest.config.js ├── .prettierrc.json ├── docs ├── .nojekyll └── assets │ ├── hierarchy.js │ ├── navigation.js │ └── highlight.css ├── scripts └── package.json ├── packages ├── experiment-core │ ├── jest.config.js │ ├── tsconfig.json │ ├── src │ │ ├── transport │ │ │ └── http.ts │ │ ├── evaluation │ │ │ ├── error.ts │ │ │ ├── select.ts │ │ │ ├── utils.ts │ │ │ ├── topological-sort.ts │ │ │ ├── flag.ts │ │ │ ├── semantic-version.ts │ │ │ └── murmur3.ts │ │ ├── util │ │ │ ├── poller.ts │ │ │ └── global.ts │ │ ├── index.ts │ │ └── api │ │ │ ├── flag-api.ts │ │ │ └── evaluation-api.ts │ ├── test │ │ ├── evaluation │ │ │ └── selector.test.ts │ │ └── api │ │ │ └── evaluation-api.test.ts │ ├── package.json │ └── rollup.config.js ├── experiment-browser │ ├── src │ │ ├── types │ │ │ ├── storage.ts │ │ │ ├── transport.ts │ │ │ ├── provider.ts │ │ │ ├── plugin.ts │ │ │ ├── variant.ts │ │ │ ├── client.ts │ │ │ ├── source.ts │ │ │ ├── logger.ts │ │ │ ├── exposure.ts │ │ │ └── analytics.ts │ │ ├── util │ │ │ ├── randomstring.ts │ │ │ ├── base64.ts │ │ │ ├── index.ts │ │ │ ├── backoff.ts │ │ │ ├── userSessionExposureTracker.ts │ │ │ ├── sessionAnalyticsProvider.ts │ │ │ ├── state.ts │ │ │ └── convert.ts │ │ ├── storage │ │ │ ├── local-storage.ts │ │ │ └── session-storage.ts │ │ ├── index.ts │ │ ├── stubClient.ts │ │ ├── logger │ │ │ ├── consoleLogger.ts │ │ │ └── ampLogger.ts │ │ ├── transport │ │ │ └── http.ts │ │ ├── providers │ │ │ └── amplitude.ts │ │ └── factory.ts │ ├── test │ │ ├── util │ │ │ ├── misc.ts │ │ │ └── mock.ts │ │ ├── base64.test.ts │ │ ├── factory.test.ts │ │ ├── logger │ │ │ └── ampLogger.test.ts │ │ └── storage.test.ts │ ├── tsconfig.test.json │ ├── jest.config.js │ ├── tsconfig.json │ ├── package.json │ └── rollup.config.js ├── plugin-segment │ ├── src │ │ ├── global.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── plugin.ts │ │ ├── snippet.ts │ │ └── plugin.ts │ ├── tsconfig.test.json │ ├── jest.config.js │ ├── tsconfig.json │ ├── package.json │ └── rollup.config.js ├── analytics-connector │ ├── src │ │ ├── util │ │ │ ├── global.ts │ │ │ └── equals.ts │ │ ├── index.ts │ │ ├── applicationContextProvider.ts │ │ ├── analyticsConnector.ts │ │ └── eventBridge.ts │ ├── tsconfig.test.json │ ├── test │ │ ├── analyticsConnector.test.ts │ │ ├── equals.test.ts │ │ └── eventBridge.test.ts │ ├── jest.config.js │ ├── tsconfig.json │ ├── package.json │ └── rollup.config.js └── experiment-tag │ ├── tsconfig.test.json │ ├── tsconfig.json │ ├── src │ ├── util │ │ ├── patch.ts │ │ ├── anti-flicker.ts │ │ ├── cookie.ts │ │ ├── uuid.ts │ │ ├── variant.ts │ │ ├── storage.ts │ │ ├── campaign.ts │ │ └── url.ts │ ├── preview │ │ ├── http.ts │ │ └── preview-api.ts │ ├── index.ts │ ├── types.ts │ └── message-bus.ts │ ├── jest.config.js │ ├── README.md │ ├── test │ └── util │ │ ├── create-page-object.ts │ │ ├── mock-http-client.ts │ │ └── create-flag.ts │ ├── package.json │ └── rollup.config.js ├── babel.config.js ├── babel.es2015.config.js ├── lerna.json ├── tsconfig.json ├── .gitignore ├── .github ├── pull_request_template.md └── workflows │ ├── lint.yml │ ├── jira-issue-create.yml │ ├── test.yml │ ├── semantic-pr.yml │ └── publish-to-s3.yml ├── .eslintrc.js ├── LICENSE ├── package.json ├── README.md └── CONTRIBUTING.md /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | example/ 3 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | example/ 3 | *.md 4 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ['/packages/*/jest.config.js'], 3 | }; 4 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /examples/react-app/basic/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /examples/react-app/basic/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/HEAD/examples/react-app/basic/public/favicon.ico -------------------------------------------------------------------------------- /examples/react-app/basic/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/HEAD/examples/react-app/basic/public/logo192.png -------------------------------------------------------------------------------- /examples/react-app/basic/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/HEAD/examples/react-app/basic/public/logo512.png -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- 1 | TypeDoc added this file to prevent GitHub Pages from using Jekyll. You can turn off this behavior by setting the `githubPages` option to false. -------------------------------------------------------------------------------- /examples/html-app/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-app", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "npx http-server" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /scripts/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "experiment-scripts", 3 | "private": true, 4 | "dependencies": { 5 | "@aws-sdk/client-s3": "^3.229.0" 6 | } 7 | } -------------------------------------------------------------------------------- /examples/html-app/amplitude-integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-app", 3 | "version": "1.0.0", 4 | "scripts": { 5 | "start": "npx http-server" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/HEAD/examples/react-app/amplitude-integration/public/favicon.ico -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/HEAD/examples/react-app/amplitude-integration/public/logo192.png -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/HEAD/examples/react-app/amplitude-integration/public/logo512.png -------------------------------------------------------------------------------- /packages/experiment-core/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | }; 6 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/storage.ts: -------------------------------------------------------------------------------- 1 | export interface Storage { 2 | get(key: string): string; 3 | put(key: string, value: string): void; 4 | delete(key: string): void; 5 | } 6 | -------------------------------------------------------------------------------- /packages/plugin-segment/src/global.ts: -------------------------------------------------------------------------------- 1 | export const safeGlobal = 2 | typeof globalThis !== 'undefined' 3 | ? globalThis 4 | : typeof global !== 'undefined' 5 | ? global 6 | : self; 7 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/util/global.ts: -------------------------------------------------------------------------------- 1 | export const safeGlobal = 2 | typeof globalThis !== 'undefined' 3 | ? globalThis 4 | : typeof global !== 'undefined' 5 | ? global 6 | : self; 7 | -------------------------------------------------------------------------------- /packages/plugin-segment/src/index.ts: -------------------------------------------------------------------------------- 1 | export { segmentIntegrationPlugin } from './plugin'; 2 | export { segmentIntegrationPlugin as plugin } from './plugin'; 3 | export { SegmentIntegrationPlugin, Options } from './types/plugin'; 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | presets: [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | browsers: ['ie >= 8'], 10 | }, 11 | }, 12 | ], 13 | '@babel/preset-typescript', 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /babel.es2015.config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | presets: [ 5 | [ 6 | '@babel/preset-env', 7 | { 8 | targets: { 9 | browsers: ['chrome 10'], 10 | }, 11 | }, 12 | ], 13 | '@babel/preset-typescript', 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /packages/experiment-browser/test/util/misc.ts: -------------------------------------------------------------------------------- 1 | export const clearAllCookies = () => { 2 | const cookies = document.cookie.split(';'); 3 | 4 | for (const cookie of cookies) { 5 | const cookieName = cookie.split('=')[0].trim(); 6 | document.cookie = `${cookieName}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /packages/experiment-tag/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "rootDir": ".", 6 | "baseUrl": ".", 7 | "paths": { 8 | "src/*": ["./src/*"] 9 | } 10 | }, 11 | "include": ["src/**/*.ts", "test/**/*.ts"], 12 | "exclude": ["dist"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/plugin-segment/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "rootDir": ".", 6 | "baseUrl": ".", 7 | "paths": { 8 | "src/*": ["./src/*"] 9 | } 10 | }, 11 | "include": ["src/**/*.ts", "test/**/*.ts"], 12 | "exclude": ["dist"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/analytics-connector/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "rootDir": ".", 6 | "baseUrl": ".", 7 | "paths": { 8 | "src/*": ["./src/*"] 9 | } 10 | }, 11 | "include": ["src/**/*.ts", "test/**/*.ts"], 12 | "exclude": ["dist"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/experiment-browser/tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "rootDir": ".", 6 | "baseUrl": ".", 7 | "paths": { 8 | "src/*": ["./src/*"] 9 | } 10 | }, 11 | "include": ["src/**/*.ts", "test/**/*.ts"], 12 | "exclude": ["dist"] 13 | } 14 | -------------------------------------------------------------------------------- /examples/html-app/basic/README.md: -------------------------------------------------------------------------------- 1 | This is a demo project for implementing Amplitude Experiment in a basic HTML page. 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm start 9 | # or 10 | yarn start 11 | ``` 12 | 13 | Open [http://localhost:8080](http://localhost:8080) with your browser to see the result. 14 | -------------------------------------------------------------------------------- /packages/experiment-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts", "package.json"], 4 | "compilerOptions": { 5 | "declaration": true, 6 | "declarationDir": "dist/types", 7 | "downlevelIteration": true, 8 | "strict": true, 9 | "baseUrl": "./src", 10 | "rootDir": "." 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent", 6 | "npmClient": "yarn", 7 | "command": { 8 | "version": { 9 | "allowBranch": "main", 10 | "conventionalCommits": true, 11 | "createRelease": "github", 12 | "message": "chore(release): publish", 13 | "preid": "beta" 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/transport.ts: -------------------------------------------------------------------------------- 1 | export interface SimpleResponse { 2 | status: number; 3 | body: string; 4 | } 5 | 6 | export interface HttpClient { 7 | request( 8 | requestUrl: string, 9 | method: string, 10 | headers: Record, 11 | data: string, 12 | timeoutMillis?: number, 13 | ): Promise; 14 | } 15 | -------------------------------------------------------------------------------- /packages/experiment-tag/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts", "package.json"], 4 | "compilerOptions": { 5 | "noImplicitAny": false, 6 | "declaration": true, 7 | "declarationDir": "dist/types", 8 | "downlevelIteration": true, 9 | "strict": true, 10 | "baseUrl": "./src", 11 | "rootDir": "." 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es5", "es6", "dom"], 4 | "module": "es6", 5 | "noEmitOnError": true, 6 | "esModuleInterop": true, 7 | "moduleResolution": "node", 8 | "resolveJsonModule": true, 9 | "target": "es5", 10 | "noEmit": true, 11 | "rootDir": ".", 12 | "baseUrl": ".", 13 | "downlevelIteration": true 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/html-app/amplitude-integration/README.md: -------------------------------------------------------------------------------- 1 | This is a demo project for implementing Amplitude Experiment using Amplitude Analytics SDK integration in a basic HTML page. 2 | 3 | ## Getting Started 4 | 5 | First, run the development server: 6 | 7 | ```bash 8 | npm start 9 | # or 10 | yarn start 11 | ``` 12 | 13 | Open [http://localhost:8080](http://localhost:8080) with your browser to see the result. 14 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/randomstring.ts: -------------------------------------------------------------------------------- 1 | const CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; 2 | 3 | export const randomString = ( 4 | length: number, 5 | alphabet: string = CHARS, 6 | ): string => { 7 | let str = ''; 8 | for (let i = 0; i < length; ++i) { 9 | str += alphabet.charAt(Math.floor(Math.random() * alphabet.length)); 10 | } 11 | return str; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/experiment-core/src/transport/http.ts: -------------------------------------------------------------------------------- 1 | export type HttpRequest = { 2 | requestUrl: string; 3 | method: string; 4 | headers: Record; 5 | body?: string; 6 | timeoutMillis?: number; 7 | }; 8 | 9 | export type HttpResponse = { 10 | status: number; 11 | body: string; 12 | }; 13 | 14 | export interface HttpClient { 15 | request(request: HttpRequest): Promise; 16 | } 17 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/patch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Patch removeChild to avoid errors when removing nodes that are added 3 | * mutate/inject actions. 4 | */ 5 | export const patchRemoveChild = () => { 6 | HTMLElement.prototype.removeChild = function (n: T): T { 7 | if (!n || n.parentNode === this) { 8 | return Node.prototype.removeChild.call(this, n) as T; 9 | } 10 | return n; 11 | }; 12 | }; 13 | -------------------------------------------------------------------------------- /examples/react-app/basic/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/index.ts: -------------------------------------------------------------------------------- 1 | export { AnalyticsConnector } from './analyticsConnector'; 2 | export { 3 | EventBridge, 4 | AnalyticsEvent, 5 | AnalyticsEventReceiver, 6 | } from './eventBridge'; 7 | export { 8 | ApplicationContext, 9 | ApplicationContextProvider, 10 | } from './applicationContextProvider'; 11 | export { 12 | Identity, 13 | IdentityStore, 14 | IdentityListener, 15 | IdentityEditor, 16 | } from './identityStore'; 17 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/provider.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentUser } from './user'; 2 | 3 | /** 4 | * An ExperimentUserProvider injects information into the {@link ExperimentUser} 5 | * object before sending a request to the server. This can be used to pass 6 | * identity (deviceId and userId), or other platform specific context. 7 | * @category Provider 8 | */ 9 | export interface ExperimentUserProvider { 10 | getUser(): ExperimentUser; 11 | } 12 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Modules 9 | node_modules/ 10 | jspm_packages/ 11 | 12 | # Output folders 13 | build/ 14 | dist/ 15 | 16 | # caches 17 | .cache 18 | 19 | # MacOS 20 | .DS_Store 21 | 22 | # WebStorm IDE 23 | .idea 24 | 25 | # For CI to ignore .npmrc file when publishing 26 | .npmrc 27 | 28 | # Example Experiment tag script 29 | packages/experiment-tag/example/ 30 | 31 | # dotenv files 32 | .env* 33 | -------------------------------------------------------------------------------- /docs/assets/hierarchy.js: -------------------------------------------------------------------------------- 1 | window.hierarchyData = "eJyVlLFugzAQht/lZqeJbSCYLao6VOoQqeoUZaBwIVaNiWxTtYry7hVBrWhi4rAwwJ2/j+N+jmCaxlnINiyJCBMp4emScJqQiMZbAgZ3CgsnG20hOwKLu6vOa4QMVvVBSdeW+GbRrE3zKUs0QOBD6hIyylICrVGQQaFya9HOvQ0Pe1crIH0RZOBsOetOmPU3TgR44oOudK6+nSzsJPJVVxgfL3z4Z+2wMnk3mbVqK6nvwl91hfFCDPCvrn1/+jqgkTVq96gkancL7KsPIylnA+YU3nQWS6IB64LA4uSXILVDs8sLtPPA0X+V3cO9VKVBDdmGckaE2HZIkXpfb3yj/B43OqfK8aQT4zTxinnzFXK6K2MjOiw+66RLr87FvodEAos+NhHRj0QMRzIeOr/E/XEbsYgXnUREh7+9l6aqgt+hL5qKiyJ+5kV8mIlG20bhBdYTvn+FoeSdTj/ByQRw" -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/error.ts: -------------------------------------------------------------------------------- 1 | export class FetchError extends Error { 2 | statusCode: number; 3 | 4 | constructor(statusCode: number, message: string) { 5 | super(message); 6 | this.statusCode = statusCode; 7 | Object.setPrototypeOf(this, FetchError.prototype); 8 | } 9 | } 10 | 11 | export class TimeoutError extends Error { 12 | constructor(message: string) { 13 | super(message); 14 | Object.setPrototypeOf(this, TimeoutError.prototype); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | ### Summary 8 | 9 | 10 | 11 | ### Checklist 12 | 13 | * [ ] Does your PR title have the correct [title format](https://github.com/amplitude/experiment-js-client/blob/main/CONTRIBUTING.md#pr-commit-title-conventions)? 14 | * Does your PR have a breaking change?: 15 | -------------------------------------------------------------------------------- /packages/experiment-browser/test/base64.test.ts: -------------------------------------------------------------------------------- 1 | import { stringToUtf8Array, urlSafeBase64Encode } from '../src/util/base64'; 2 | 3 | test('stringToUtf8Array', () => { 4 | expect(stringToUtf8Array('My 🚀 is full of 🦎')).toEqual([ 5 | 77, 121, 32, 240, 159, 154, 128, 32, 105, 115, 32, 102, 117, 108, 108, 32, 6 | 111, 102, 32, 240, 159, 166, 142, 7 | ]); 8 | }); 9 | 10 | test('urlSafeBase64Encode', () => { 11 | expect(urlSafeBase64Encode('My 🚀 is full of 🦎')).toEqual( 12 | 'TXkg8J-agCBpcyBmdWxsIG9mIPCfpo4', 13 | ); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/analytics-connector/test/analyticsConnector.test.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsConnector } from 'src/analyticsConnector'; 2 | 3 | test('AnalyticsConnector.getInstance returns the same instance', async () => { 4 | const connector = AnalyticsConnector.getInstance('$default_instance'); 5 | connector.identityStore.setIdentity({ userId: 'userId' }); 6 | 7 | const connector2 = AnalyticsConnector.getInstance('$default_instance'); 8 | const identity = connector2.identityStore.getIdentity(); 9 | expect(identity).toEqual({ userId: 'userId' }); 10 | }); 11 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/storage/local-storage.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | import { Storage } from '../types/storage'; 4 | export class LocalStorage implements Storage { 5 | globalScope = getGlobalScope(); 6 | get(key: string): string { 7 | return this.globalScope?.localStorage.getItem(key); 8 | } 9 | 10 | put(key: string, value: string): void { 11 | this.globalScope?.localStorage.setItem(key, value); 12 | } 13 | 14 | delete(key: string): void { 15 | this.globalScope?.localStorage.removeItem(key); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/storage/session-storage.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | import { Storage } from '../types/storage'; 4 | export class SessionStorage implements Storage { 5 | globalScope = getGlobalScope(); 6 | get(key: string): string { 7 | return this.globalScope?.sessionStorage.getItem(key); 8 | } 9 | 10 | put(key: string, value: string): void { 11 | this.globalScope?.sessionStorage.setItem(key, value); 12 | } 13 | 14 | delete(key: string): void { 15 | this.globalScope?.sessionStorage.removeItem(key); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /docs/assets/navigation.js: -------------------------------------------------------------------------------- 1 | window.navigationData = "eJyVlUtPwkAQx7/Lnokoig9uhmBiQqIJPg7Gw9oOZcKy2+xOCWj87qag0nZnt+U6j9+/89q+fQmCDYmRmJpsCmtQoidySQsxEqCLlev/2U8WtCqdS9SpGF1/9/4zZ6awCTTz9tZw1u0qV0hFCrdaqi1h4h6tWWMK9kBKlHQOXD8cW+efDa57+yQxEinkFhJJkApO9l4TZFYSGv2oigx1RNaL9WQ5hWcHtkNN1bCjyxkb7YyCqckyTqTmjn30ZJODxRVoGisETT6qGRGjzaj4aCdyUTFqk4OawM5lUtbJJA+Gl2yB/zs0WYdwoWBPoG08DMjfiLgwvxpHaY+NnmPWIrgP6tbC5sGwQO5SQkD+VFhs+Fw8uHGFhScrkyXqrA3PBscEIg9IhdzydNSRzTOucLgTDnWzsdi0zWsd5Fb59ObqbDiIDPtpm0MYeYjpzi0HGSaW3g6s3dQYys7eln8HlCwe8nI2rsmo+to4L9Ki9Dv+a+6Y7X3Bn717Rw+EdZn7oWodrXPOqwjUSCgVflZaOS90squ+f/DWEZcXLOIVaeH/sONgPseTe/8BJ6wGUg==" -------------------------------------------------------------------------------- /packages/experiment-tag/jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { pathsToModuleNameMapper } = require('ts-jest'); 3 | 4 | const package = require('./package'); 5 | const { compilerOptions } = require('./tsconfig.test.json'); 6 | 7 | module.exports = { 8 | preset: 'ts-jest', 9 | testEnvironment: 'jsdom', 10 | displayName: package.name, 11 | rootDir: '.', 12 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 13 | prefix: '/', 14 | }), 15 | transform: { 16 | '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/anti-flicker.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | export const applyAntiFlickerCss = () => { 4 | const globalScope = getGlobalScope(); 5 | if (!globalScope?.document.getElementById('amp-exp-css')) { 6 | const id = 'amp-exp-css'; 7 | const s = document.createElement('style'); 8 | s.id = id; 9 | s.innerText = 10 | '* { visibility: hidden !important; background-image: none !important; }'; 11 | document.head.appendChild(s); 12 | globalScope?.window.setTimeout(function () { 13 | s.remove(); 14 | }, 1000); 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /packages/plugin-segment/jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { pathsToModuleNameMapper } = require('ts-jest'); 3 | 4 | const package = require('./package'); 5 | const { compilerOptions } = require('./tsconfig.test.json'); 6 | 7 | module.exports = { 8 | preset: 'ts-jest', 9 | testEnvironment: 'jsdom', 10 | displayName: package.name, 11 | rootDir: '.', 12 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 13 | prefix: '/', 14 | }), 15 | transform: { 16 | '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/analytics-connector/jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { pathsToModuleNameMapper } = require('ts-jest'); 3 | 4 | const package = require('./package'); 5 | const { compilerOptions } = require('./tsconfig.test.json'); 6 | 7 | module.exports = { 8 | preset: 'ts-jest', 9 | testEnvironment: 'jsdom', 10 | displayName: package.name, 11 | rootDir: '.', 12 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 13 | prefix: '/', 14 | }), 15 | transform: { 16 | '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /examples/react-app/basic/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import { Experiment } from '@amplitude/experiment-js-client'; 6 | 7 | /** 8 | * Initialize the Amplitude Experiment SDK and export the initialized client. 9 | */ 10 | export const experiment = Experiment.initialize( 11 | 'DEPLOYMENT_KEY', 12 | { debug: true } 13 | ); 14 | 15 | const root = ReactDOM.createRoot( 16 | document.getElementById('root') as HTMLElement 17 | ); 18 | root.render( 19 | 20 | 21 | 22 | ); 23 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/base64.ts: -------------------------------------------------------------------------------- 1 | import * as base64 from 'base64-js'; 2 | 3 | export const stringToUtf8Array = (s: string): Array => { 4 | const utf8 = unescape(encodeURIComponent(s)); 5 | const arr = []; 6 | for (let i = 0; i < utf8.length; i++) { 7 | arr.push(utf8.charCodeAt(i)); 8 | } 9 | return arr; 10 | }; 11 | 12 | export const urlSafeBase64Encode = (s: string): string => { 13 | const base64encoded = base64.fromByteArray( 14 | new Uint8Array(stringToUtf8Array(s)), 15 | ); 16 | return base64encoded 17 | .replace(/=/g, '') 18 | .replace(/\+/g, '-') 19 | .replace(/\//g, '_'); 20 | }; 21 | -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/select.ts: -------------------------------------------------------------------------------- 1 | export const select = ( 2 | selectable: unknown, 3 | selector: string[] | undefined, 4 | ): unknown | undefined => { 5 | if (!selector || selector.length === 0) { 6 | return undefined; 7 | } 8 | for (const selectorElement of selector) { 9 | if (!selectorElement || !selectable || typeof selectable !== 'object') { 10 | return undefined; 11 | } 12 | selectable = (selectable as Record)[selectorElement]; 13 | } 14 | if (selectable === undefined || selectable === null) { 15 | return undefined; 16 | } else { 17 | return selectable; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /packages/experiment-browser/jest.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const { pathsToModuleNameMapper } = require('ts-jest'); 3 | 4 | const package = require('./package'); 5 | const { compilerOptions } = require('./tsconfig.test.json'); 6 | 7 | module.exports = { 8 | preset: 'ts-jest', 9 | testEnvironment: 'jsdom', 10 | displayName: package.name, 11 | rootDir: '.', 12 | moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths, { 13 | prefix: '/', 14 | }), 15 | transform: { 16 | '^.+\\.tsx?$': ['ts-jest', { tsconfig: './tsconfig.test.json' }], 17 | }, 18 | testTimeout: 10 * 1000, 19 | }; 20 | -------------------------------------------------------------------------------- /examples/react-app/basic/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/plugin-segment/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts", "package.json"], 4 | "typedocOptions": { 5 | "name": "Experiment JS Client Documentation", 6 | "entryPoints": ["./src/index.ts"], 7 | "categoryOrder": [ 8 | "Core Usage", 9 | "Configuration", 10 | "Context Provider", 11 | "Types" 12 | ], 13 | "categorizeByGroup": false, 14 | "disableSources": true, 15 | "excludePrivate": true, 16 | "excludeProtected": true, 17 | "excludeInternal": true, 18 | "hideGenerator": true, 19 | "includeVersion": true, 20 | "out": "../../docs", 21 | "readme": "none", 22 | "theme": "minimal" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/analytics-connector/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts", "package.json"], 4 | "typedocOptions": { 5 | "name": "Experiment JS Client Documentation", 6 | "entryPoints": ["./src/index.ts"], 7 | "categoryOrder": [ 8 | "Core Usage", 9 | "Configuration", 10 | "Context Provider", 11 | "Types" 12 | ], 13 | "categorizeByGroup": false, 14 | "disableSources": true, 15 | "excludePrivate": true, 16 | "excludeProtected": true, 17 | "excludeInternal": true, 18 | "hideGenerator": true, 19 | "includeVersion": true, 20 | "out": "../../docs", 21 | "readme": "none", 22 | "theme": "minimal" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /packages/experiment-tag/README.md: -------------------------------------------------------------------------------- 1 | # Experiment Web Experimentation Javascript Snippet 2 | 3 | ## Overview 4 | 5 | This is the Web Experimentation SDK for Amplitude Experiment. 6 | 7 | ## Generate example 8 | 9 | To generate an example snippet with custom flag configurations: 10 | 1. Set `apiKey` (your Amplitude Project API key), `initialFlags` and `serverZone` in `example/build_example.js` 11 | 2. Run `yarn build` to build minified UMD `experiment-tag.umd.js` and example `script.js` 12 | 13 | To test the snippet's behavior on web pages relevant to your experiment, the pages should: 14 | 1. Include `script.js` 15 | 2. Have the Amplitude Analytics SDK loaded (see [examples](https://github.com/amplitude/Amplitude-TypeScript/tree/main/packages/analytics-browser)) 16 | 17 | -------------------------------------------------------------------------------- /packages/experiment-browser/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src/**/*.ts", "package.json"], 4 | "typedocOptions": { 5 | "name": "Experiment JS Client Documentation", 6 | "entryPoints": ["./src/index.ts"], 7 | "categoryOrder": [ 8 | "Core Usage", 9 | "Configuration", 10 | "Context Provider", 11 | "Types" 12 | ], 13 | "categorizeByGroup": false, 14 | "disableSources": true, 15 | "excludePrivate": true, 16 | "excludeProtected": true, 17 | "excludeInternal": true, 18 | "hideGenerator": true, 19 | "includeVersion": true, 20 | "out": "../../docs", 21 | "readme": "none", 22 | "theme": "default" 23 | }, 24 | "compilerOptions": { 25 | "lib": ["ES2019"] 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = { 4 | root: true, 5 | env: { 6 | browser: true, 7 | es6: true, 8 | jest: true, 9 | node: true, 10 | }, 11 | parser: '@typescript-eslint/parser', 12 | plugins: ['@typescript-eslint', 'jest', 'import', 'prettier'], 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:@typescript-eslint/recommended', 16 | 'prettier', 17 | 'prettier/@typescript-eslint', 18 | ], 19 | rules: { 20 | 'no-console': ['error', { allow: ['warn', 'error', 'debug'] }], 21 | 22 | // eslint-plugin-import 23 | 'import/order': [ 24 | 'error', 25 | { 'newlines-between': 'always', alphabetize: { order: 'asc' } }, 26 | ], 27 | 28 | // eslint-plugin-prettier 29 | 'prettier/prettier': 'error', 30 | }, 31 | }; 32 | -------------------------------------------------------------------------------- /packages/analytics-connector/test/equals.test.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'src/util/equals'; 2 | 3 | describe('isEqual', () => { 4 | test('isEqual, one null on non-null, is false', () => { 5 | const actual = isEqual('non-null', null); 6 | expect(actual).toEqual(false); 7 | }); 8 | 9 | test('isEqual, two null, is true', () => { 10 | const actual = isEqual(null, null); 11 | expect(actual).toEqual(true); 12 | }); 13 | 14 | test('isEqual, two non-null equals, is true', () => { 15 | const actual = isEqual('non-null', 'non-null'); 16 | expect(actual).toEqual(true); 17 | }); 18 | 19 | test('isEqual, user objects with null user ids, is true', () => { 20 | const actual = isEqual({ user_id: null }, { user_id: null }); 21 | expect(actual).toEqual(true); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /packages/experiment-core/src/util/poller.ts: -------------------------------------------------------------------------------- 1 | import { safeGlobal } from './global'; 2 | 3 | export class Poller { 4 | public readonly action: () => Promise; 5 | private readonly ms; 6 | private poller: unknown | undefined = undefined; 7 | constructor(action: () => Promise, ms: number) { 8 | this.action = action; 9 | this.ms = ms; 10 | } 11 | public start() { 12 | if (this.poller) { 13 | return; 14 | } 15 | this.poller = safeGlobal.setInterval(this.action, this.ms); 16 | void this.action(); 17 | } 18 | 19 | public stop() { 20 | if (!this.poller) { 21 | return; 22 | } 23 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 24 | // @ts-ignore 25 | safeGlobal.clearInterval(this.poller); 26 | this.poller = undefined; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentConfig } from '../config'; 2 | 3 | import { Client } from './client'; 4 | import { ExperimentUser } from './user'; 5 | 6 | type PluginTypeIntegration = 'integration'; 7 | 8 | export type ExperimentPluginType = PluginTypeIntegration; 9 | 10 | export interface ExperimentPlugin { 11 | name?: string; 12 | type?: ExperimentPluginType; 13 | setup?(config: ExperimentConfig, client: Client): Promise; 14 | teardown?(): Promise; 15 | } 16 | 17 | export type ExperimentEvent = { 18 | eventType: string; 19 | eventProperties?: Record; 20 | }; 21 | 22 | export interface IntegrationPlugin extends ExperimentPlugin { 23 | type: PluginTypeIntegration; 24 | getUser(): ExperimentUser; 25 | track(event: ExperimentEvent): boolean; 26 | } 27 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/cookie.ts: -------------------------------------------------------------------------------- 1 | import { CampaignParser } from '@amplitude/analytics-core'; 2 | import { CookieStorage } from '@amplitude/analytics-core'; 3 | import { MKTG } from '@amplitude/analytics-core'; 4 | import type { Campaign } from '@amplitude/analytics-core'; 5 | 6 | /** 7 | * Utility function to generate and set marketing cookie 8 | * Parses current campaign data from URL and referrer, then stores it in the marketing cookie 9 | */ 10 | export async function setMarketingCookie(apiKey: string) { 11 | const storage = new CookieStorage({ 12 | sameSite: 'Lax', 13 | }); 14 | 15 | const parser = new CampaignParser(); 16 | const storageKey = `AMP_${MKTG}_ORIGINAL_${apiKey.substring(0, 10)}`; 17 | const campaign = await parser.parse(); 18 | await storage.set(storageKey, campaign); 19 | } 20 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/uuid.ts: -------------------------------------------------------------------------------- 1 | export const UUID = function (a?: any): string { 2 | return a // if the placeholder was passed, return 3 | ? // a random number from 0 to 15 4 | ( 5 | a ^ // unless b is 8, 6 | ((Math.random() * // in which case 7 | 16) >> // a random number from 8 | (a / 4)) 9 | ) // 8 to 11 10 | .toString(16) // in hexadecimal 11 | : // or otherwise a concatenated string: 12 | ( 13 | String(1e7) + // 10000000 + 14 | String(-1e3) + // -1000 + 15 | String(-4e3) + // -4000 + 16 | String(-8e3) + // -80000000 + 17 | String(-1e11) 18 | ) // -100000000000, 19 | .replace( 20 | // replacing 21 | /[018]/g, // zeroes, ones, and eights with 22 | UUID, // random hex digits 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/plugin-segment/src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import { IntegrationPlugin } from '@amplitude/experiment-js-client'; 2 | import { Analytics } from '@segment/analytics-next'; 3 | 4 | export interface Options { 5 | /** 6 | * An existing segment analytics instance. This instance will be used instead 7 | * of the instance on the window defined by the instanceKey. 8 | */ 9 | instance?: Analytics; 10 | /** 11 | * The key of the field on the window that holds the segment analytics 12 | * instance when the script is loaded via the script loader. 13 | * 14 | * Defaults to "analytics". 15 | */ 16 | instanceKey?: string; 17 | /** 18 | * Skip waiting for the segment SDK to load and be ready. 19 | */ 20 | skipSetup?: boolean; 21 | } 22 | 23 | export interface SegmentIntegrationPlugin { 24 | (options?: Options): IntegrationPlugin; 25 | } 26 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/variant.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @category Types 3 | */ 4 | export type Variant = { 5 | /** 6 | * The key of the variant. 7 | */ 8 | key?: string; 9 | /** 10 | * The value of the variant. 11 | */ 12 | value?: string; 13 | 14 | /** 15 | * The attached payload, if any. 16 | */ 17 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 18 | payload?: any; 19 | 20 | /** 21 | * The experiment key. Used to distinguish two experiments associated with the same flag. 22 | */ 23 | expKey?: string; 24 | 25 | /** 26 | * Flag, segment, and variant metadata produced as a result of 27 | * evaluation for the user. Used for system purposes. 28 | */ 29 | metadata?: Record; 30 | }; 31 | 32 | /** 33 | * @category Types 34 | */ 35 | export type Variants = { 36 | [key: string]: Variant; 37 | }; 38 | -------------------------------------------------------------------------------- /packages/experiment-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { EvaluationEngine } from './evaluation/evaluation'; 2 | export { 3 | EvaluationFlag, 4 | EvaluationAllocation, 5 | EvaluationBucket, 6 | EvaluationCondition, 7 | EvaluationDistribution, 8 | EvaluationOperator, 9 | EvaluationSegment, 10 | EvaluationVariant, 11 | } from './evaluation/flag'; 12 | export { topologicalSort } from './evaluation/topological-sort'; 13 | export { 14 | EvaluationApi, 15 | SdkEvaluationApi, 16 | GetVariantsOptions, 17 | } from './api/evaluation-api'; 18 | export { FlagApi, SdkFlagApi, GetFlagsOptions } from './api/flag-api'; 19 | export { HttpClient, HttpRequest, HttpResponse } from './transport/http'; 20 | export { Poller } from './util/poller'; 21 | export { 22 | safeGlobal, 23 | getGlobalScope, 24 | isLocalStorageAvailable, 25 | } from './util/global'; 26 | export { FetchError, TimeoutError } from './evaluation/error'; 27 | -------------------------------------------------------------------------------- /examples/react-app/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@amplitude/experiment-js-client": "^1.5.6", 7 | "@types/node": "^16.11.29", 8 | "@types/react": "^18.0.7", 9 | "@types/react-dom": "^18.0.0", 10 | "react": "^18.0.0", 11 | "react-dom": "^18.0.0", 12 | "react-scripts": "5.0.1", 13 | "typescript": "^4.6.3" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "react-scripts build", 18 | "eject": "react-scripts eject" 19 | }, 20 | "eslintConfig": { 21 | "extends": [ 22 | "react-app" 23 | ] 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | wrap-option: break-word; 4 | } 5 | 6 | .App-logo { 7 | height: 20vmin; 8 | pointer-events: none; 9 | } 10 | 11 | @media (prefers-reduced-motion: no-preference) { 12 | .App-logo { 13 | animation: App-logo-spin infinite 20s linear; 14 | } 15 | } 16 | 17 | .App-header { 18 | background-color: #282c34; 19 | min-height: 100vh; 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | justify-content: center; 24 | font-size: calc(10px + 1vmin); 25 | color: white; 26 | wrap-option: anywhere; 27 | } 28 | 29 | .App-link { 30 | color: #61dafb; 31 | } 32 | 33 | @keyframes App-logo-spin { 34 | from { 35 | transform: rotate(0deg); 36 | } 37 | to { 38 | transform: rotate(360deg); 39 | } 40 | } 41 | 42 | .output { 43 | font-family: monospace, serif; 44 | font-size: calc(10px + 0.75vmin); 45 | wrap-option: break-word; 46 | } 47 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | wrap-option: break-word; 4 | } 5 | 6 | .App-logo { 7 | height: 20vmin; 8 | pointer-events: none; 9 | } 10 | 11 | @media (prefers-reduced-motion: no-preference) { 12 | .App-logo { 13 | animation: App-logo-spin infinite 20s linear; 14 | } 15 | } 16 | 17 | .App-header { 18 | background-color: #282c34; 19 | min-height: 100vh; 20 | display: flex; 21 | flex-direction: column; 22 | align-items: center; 23 | justify-content: center; 24 | font-size: calc(10px + 1vmin); 25 | color: white; 26 | wrap-option: anywhere; 27 | } 28 | 29 | .App-link { 30 | color: #61dafb; 31 | } 32 | 33 | @keyframes App-logo-spin { 34 | from { 35 | transform: rotate(0deg); 36 | } 37 | to { 38 | transform: rotate(360deg); 39 | } 40 | } 41 | 42 | .output { 43 | font-family: monospace, serif; 44 | font-size: calc(10px + 0.75vmin); 45 | wrap-option: break-word; 46 | } 47 | -------------------------------------------------------------------------------- /packages/experiment-core/src/util/global.ts: -------------------------------------------------------------------------------- 1 | export const safeGlobal = 2 | typeof globalThis !== 'undefined' ? globalThis : global || self; 3 | 4 | export const getGlobalScope = (): typeof globalThis | undefined => { 5 | if (typeof globalThis !== 'undefined') { 6 | return globalThis; 7 | } 8 | if (typeof window !== 'undefined') { 9 | return window; 10 | } 11 | if (typeof self !== 'undefined') { 12 | return self; 13 | } 14 | if (typeof global !== 'undefined') { 15 | return global; 16 | } 17 | return undefined; 18 | }; 19 | 20 | export const isLocalStorageAvailable = (): boolean => { 21 | const globalScope = getGlobalScope(); 22 | if (globalScope) { 23 | try { 24 | const testKey = 'EXP_test'; 25 | globalScope.localStorage.setItem(testKey, testKey); 26 | globalScope.localStorage.removeItem(testKey); 27 | return true; 28 | } catch (e) { 29 | return false; 30 | } 31 | } 32 | return false; 33 | }; 34 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/applicationContextProvider.ts: -------------------------------------------------------------------------------- 1 | export type ApplicationContext = { 2 | versionName?: string; 3 | language?: string; 4 | platform?: string; 5 | os?: string; 6 | deviceModel?: string; 7 | }; 8 | 9 | export interface ApplicationContextProvider { 10 | versionName: string; 11 | getApplicationContext(): ApplicationContext; 12 | } 13 | 14 | export class ApplicationContextProviderImpl 15 | implements ApplicationContextProvider 16 | { 17 | public versionName: string; 18 | getApplicationContext(): ApplicationContext { 19 | return { 20 | versionName: this.versionName, 21 | language: getLanguage(), 22 | platform: 'Web', 23 | os: undefined, 24 | deviceModel: undefined, 25 | }; 26 | } 27 | } 28 | 29 | const getLanguage = (): string => { 30 | return ( 31 | (typeof navigator !== 'undefined' && 32 | ((navigator.languages && navigator.languages[0]) || 33 | navigator.language)) || 34 | '' 35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/utils.ts: -------------------------------------------------------------------------------- 1 | export const stringToUtf8ByteArray = (str: string): Uint8Array => { 2 | const out = []; 3 | let p = 0; 4 | for (let i = 0; i < str.length; i++) { 5 | let c = str.charCodeAt(i); 6 | if (c < 128) { 7 | out[p++] = c; 8 | } else if (c < 2048) { 9 | out[p++] = (c >> 6) | 192; 10 | out[p++] = (c & 63) | 128; 11 | } else if ( 12 | (c & 0xfc00) == 0xd800 && 13 | i + 1 < str.length && 14 | (str.charCodeAt(i + 1) & 0xfc00) == 0xdc00 15 | ) { 16 | // Surrogate Pair 17 | c = 0x10000 + ((c & 0x03ff) << 10) + (str.charCodeAt(++i) & 0x03ff); 18 | out[p++] = (c >> 18) | 240; 19 | out[p++] = ((c >> 12) & 63) | 128; 20 | out[p++] = ((c >> 6) & 63) | 128; 21 | out[p++] = (c & 63) | 128; 22 | } else { 23 | out[p++] = (c >> 12) | 224; 24 | out[p++] = ((c >> 6) & 63) | 128; 25 | out[p++] = (c & 63) | 128; 26 | } 27 | } 28 | return Uint8Array.from(out); 29 | }; 30 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/analyticsConnector.ts: -------------------------------------------------------------------------------- 1 | import { ApplicationContextProviderImpl } from './applicationContextProvider'; 2 | import { EventBridgeImpl } from './eventBridge'; 3 | import { IdentityStoreImpl } from './identityStore'; 4 | import { safeGlobal } from './util/global'; 5 | 6 | export class AnalyticsConnector { 7 | public readonly identityStore = new IdentityStoreImpl(); 8 | public readonly eventBridge = new EventBridgeImpl(); 9 | public readonly applicationContextProvider = 10 | new ApplicationContextProviderImpl(); 11 | 12 | static getInstance(instanceName: string): AnalyticsConnector { 13 | if (!safeGlobal['analyticsConnectorInstances']) { 14 | safeGlobal['analyticsConnectorInstances'] = {}; 15 | } 16 | if (!safeGlobal['analyticsConnectorInstances'][instanceName]) { 17 | safeGlobal['analyticsConnectorInstances'][instanceName] = 18 | new AnalyticsConnector(); 19 | } 20 | return safeGlobal['analyticsConnectorInstances'][instanceName]; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/experiment-tag/test/util/create-page-object.ts: -------------------------------------------------------------------------------- 1 | import { MessageType } from 'src/message-bus'; 2 | import { PageObject } from 'src/types'; 3 | 4 | const DUMMY_TRUE_CONDITION = [ 5 | { 6 | op: 'is', 7 | selector: [], 8 | values: ['(none)'], 9 | }, 10 | ]; 11 | 12 | export const createPageObject = ( 13 | id: string, 14 | triggerType: MessageType, 15 | triggerProperties?: Record, 16 | urlContains?: string, 17 | ): Record => { 18 | let conditions: any[] = [DUMMY_TRUE_CONDITION]; 19 | if (triggerType === 'url_change') { 20 | conditions = [ 21 | [ 22 | { 23 | op: 'regex match', 24 | selector: ['context', 'page', 'url'], 25 | values: [`.*${urlContains}.*`], 26 | }, 27 | ], 28 | ]; 29 | } 30 | return { 31 | [id]: { 32 | id, 33 | name: id, 34 | conditions: conditions, 35 | trigger_type: triggerType, 36 | trigger_value: { 37 | ...triggerProperties, 38 | }, 39 | }, 40 | }; 41 | }; 42 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@amplitude/analytics-browser": "^1.12.1", 7 | "@amplitude/experiment-js-client": "^1.5.6", 8 | "@types/node": "^16.11.29", 9 | "@types/react": "^18.0.7", 10 | "@types/react-dom": "^18.0.0", 11 | "react": "^18.0.0", 12 | "react-dom": "^18.0.0", 13 | "typescript": "^4.6.3" 14 | }, 15 | "devDependencies": { 16 | "react-scripts": "^5.0.1" 17 | }, 18 | "scripts": { 19 | "start": "react-scripts start", 20 | "build": "react-scripts build", 21 | "eject": "react-scripts eject" 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "react-app" 26 | ] 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /packages/experiment-tag/test/util/mock-http-client.ts: -------------------------------------------------------------------------------- 1 | // interfaces copied frm experiment-browser 2 | 3 | interface SimpleResponse { 4 | status: number; 5 | body: string; 6 | } 7 | 8 | interface HttpClient { 9 | request( 10 | requestUrl: string, 11 | method: string, 12 | headers: Record, 13 | data: string, 14 | timeoutMillis?: number, 15 | ): Promise; 16 | } 17 | 18 | export class MockHttpClient implements HttpClient { 19 | private response: SimpleResponse; 20 | public requestUrl; 21 | public requestHeader; 22 | 23 | constructor(responseBody: string, status = 200) { 24 | this.response = { 25 | status, 26 | body: responseBody, 27 | }; 28 | } 29 | 30 | request( 31 | requestUrl: string, 32 | method: string, 33 | headers: Record, 34 | data: string, 35 | timeoutMillis?: number, 36 | ): Promise { 37 | this.requestUrl = requestUrl; 38 | this.requestHeader = headers; 39 | return Promise.resolve(this.response); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out Git repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Cache Node Modules 17 | uses: actions/cache@v4 18 | with: 19 | path: '**/node_modules' 20 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 21 | 22 | - name: Setup Node.js 23 | uses: actions/setup-node@v4 24 | with: 25 | node-version: '22' 26 | 27 | - name: Set up SSH for deploy key 28 | run: | 29 | mkdir -p ~/.ssh 30 | echo "${{ secrets.DOM_MUTATOR_ACCESS_KEY }}" > ~/.ssh/id_ed25519 31 | chmod 600 ~/.ssh/id_ed25519 32 | ssh-keyscan github.com >> ~/.ssh/known_hosts 33 | continue-on-error: true # forked repos don't have access, and this is only for experiment-tag 34 | shell: bash 35 | 36 | - name: Install 37 | run: yarn install --frozen-lockfile 38 | 39 | - name: Lint 40 | run: yarn lint 41 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/eventBridge.ts: -------------------------------------------------------------------------------- 1 | export type AnalyticsEvent = { 2 | eventType: string; 3 | eventProperties?: Record; 4 | userProperties?: Record; 5 | }; 6 | 7 | export type AnalyticsEventReceiver = (event: AnalyticsEvent) => void; 8 | 9 | export interface EventBridge { 10 | logEvent(event: AnalyticsEvent): void; 11 | setEventReceiver(listener: AnalyticsEventReceiver): void; 12 | } 13 | 14 | export class EventBridgeImpl implements EventBridge { 15 | private receiver: AnalyticsEventReceiver; 16 | private queue: AnalyticsEvent[] = []; 17 | 18 | logEvent(event: AnalyticsEvent): void { 19 | if (!this.receiver) { 20 | if (this.queue.length < 512) { 21 | this.queue.push(event); 22 | } 23 | } else { 24 | this.receiver(event); 25 | } 26 | } 27 | 28 | setEventReceiver(receiver: AnalyticsEventReceiver): void { 29 | this.receiver = receiver; 30 | if (this.queue.length > 0) { 31 | this.queue.forEach((event) => { 32 | receiver(event); 33 | }); 34 | this.queue = []; 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom/client'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as amplitude from '@amplitude/analytics-browser'; 6 | import { Experiment } from '@amplitude/experiment-js-client'; 7 | 8 | /** 9 | * Initialize the Amplitude Analytics SDK. 10 | */ 11 | amplitude.init('API_KEY', 'user@company.com'); 12 | amplitude.identify(new amplitude.Identify().set('premium', true)) 13 | 14 | /** 15 | * Initialize the Amplitude Experiment SDK with the Amplitude Analytics 16 | * integration and export the initialized client. 17 | * 18 | * The user identity and user properties set in the analytics SDK will 19 | * automatically be used by the Experiment SDK on fetch(). 20 | */ 21 | export const experiment = Experiment.initializeWithAmplitudeAnalytics( 22 | 'DEPLOYMENT_KEY', 23 | { debug: true } 24 | ); 25 | 26 | const root = ReactDOM.createRoot( 27 | document.getElementById('root') as HTMLElement 28 | ); 29 | root.render( 30 | 31 | 32 | 33 | ); 34 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/variant.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationVariant } from '@amplitude/experiment-core'; 2 | import { Variant } from '@amplitude/experiment-js-client'; 3 | 4 | export const convertEvaluationVariantToVariant = ( 5 | evaluationVariant: EvaluationVariant, 6 | ): Variant => { 7 | if (!evaluationVariant) { 8 | return {}; 9 | } 10 | let experimentKey: string | undefined = undefined; 11 | if (evaluationVariant.metadata) { 12 | if (typeof evaluationVariant.metadata['experimentKey'] === 'string') { 13 | experimentKey = evaluationVariant.metadata['experimentKey']; 14 | } else { 15 | experimentKey = undefined; 16 | } 17 | } 18 | const variant: Variant = {}; 19 | if (evaluationVariant.key) variant.key = evaluationVariant.key; 20 | if (evaluationVariant.value) 21 | variant.value = evaluationVariant.value as string; 22 | if (evaluationVariant.payload) variant.payload = evaluationVariant.payload; 23 | if (experimentKey) variant.expKey = experimentKey; 24 | if (evaluationVariant.metadata) variant.metadata = evaluationVariant.metadata; 25 | return variant; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/client.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentUserProvider } from './provider'; 2 | import { ExperimentUser } from './user'; 3 | import { Variant, Variants } from './variant'; 4 | 5 | export type FetchOptions = { 6 | /** 7 | * When set, fetch will only request variants for the given flag keys. 8 | */ 9 | flagKeys?: string[]; 10 | }; 11 | 12 | /** 13 | * Interface for the main client. 14 | * @category Core Usage 15 | */ 16 | export interface Client { 17 | start(user?: ExperimentUser): Promise; 18 | stop(): void; 19 | fetch(user?: ExperimentUser, options?: FetchOptions): Promise; 20 | variant(key: string, fallback?: string | Variant): Variant; 21 | all(): Variants; 22 | clear(): void; 23 | exposure(key: string): void; 24 | getUser(): ExperimentUser; 25 | setUser(user: ExperimentUser): void; 26 | 27 | /** 28 | * @deprecated use ExperimentConfig.userProvider instead 29 | */ 30 | getUserProvider(): ExperimentUserProvider; 31 | /** 32 | * @deprecated use ExperimentConfig.userProvider instead 33 | */ 34 | setUserProvider(userProvider: ExperimentUserProvider): Client; 35 | } 36 | -------------------------------------------------------------------------------- /packages/experiment-core/test/evaluation/selector.test.ts: -------------------------------------------------------------------------------- 1 | import { select } from '../../src/evaluation/select'; 2 | 3 | const primitiveObject = { 4 | null: null, 5 | string: 'value', 6 | number: 13, 7 | boolean: true, 8 | }; 9 | const nestedObject = { 10 | ...primitiveObject, 11 | object: primitiveObject, 12 | }; 13 | 14 | test('test selector evaluation context types', () => { 15 | const context = nestedObject; 16 | expect(select(context, ['does', 'not', 'exist'])).toBeUndefined(); 17 | expect(select(context, ['null'])).toBeUndefined(); 18 | expect(select(context, ['string'])).toEqual('value'); 19 | expect(select(context, ['number'])).toEqual(13); 20 | expect(select(context, ['boolean'])).toEqual(true); 21 | expect(select(context, ['object'])).toEqual(primitiveObject); 22 | expect(select(context, ['object', 'does', 'not', 'exist'])).toBeUndefined(); 23 | expect(select(context, ['object', 'null'])).toBeUndefined(); 24 | expect(select(context, ['object', 'string'])).toEqual('value'); 25 | expect(select(context, ['object', 'number'])).toEqual(13); 26 | expect(select(context, ['object', 'boolean'])).toEqual(true); 27 | }); 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Amplitude Analytics 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /packages/analytics-connector/test/eventBridge.test.ts: -------------------------------------------------------------------------------- 1 | import { EventBridgeImpl } from 'src/eventBridge'; 2 | 3 | test('setEventReceiver, logEvent, listener called', async () => { 4 | const eventBridge = new EventBridgeImpl(); 5 | const expectedEvent = { eventType: 'test' }; 6 | eventBridge.setEventReceiver((event) => { 7 | expect(event).toEqual(expectedEvent); 8 | }); 9 | }); 10 | 11 | test('multiple logEvent, late setEventReceiver, listener called', async () => { 12 | const expectedEvent0 = { eventType: 'test0' }; 13 | const expectedEvent1 = { eventType: 'test1' }; 14 | const expectedEvent2 = { eventType: 'test2' }; 15 | const eventBridge = new EventBridgeImpl(); 16 | eventBridge.logEvent(expectedEvent0); 17 | eventBridge.logEvent(expectedEvent1); 18 | eventBridge.logEvent(expectedEvent2); 19 | let count = 0; 20 | eventBridge.setEventReceiver((event) => { 21 | if (count == 0) { 22 | expect(event).toEqual(expectedEvent0); 23 | } else if (count == 1) { 24 | expect(event).toEqual(expectedEvent1); 25 | } else if (count == 2) { 26 | expect(event).toEqual(expectedEvent2); 27 | } 28 | count++; 29 | }); 30 | expect(count).toEqual(3); 31 | }); 32 | -------------------------------------------------------------------------------- /packages/experiment-browser/test/util/mock.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '../../src'; 2 | import { Storage } from '../../src/types/storage'; 3 | 4 | export const mockClientStorage = (client: Client) => { 5 | const storage = new MockStorage(); 6 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 7 | // @ts-ignore 8 | client.variants.storage = storage; 9 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 10 | // @ts-ignore 11 | client.flags.storage = storage; 12 | 13 | // Clear the in-memory caches to ensure test isolation 14 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 15 | // @ts-ignore 16 | client.variants.clear(); 17 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 18 | // @ts-ignore 19 | client.flags.clear(); 20 | }; 21 | 22 | class MockStorage implements Storage { 23 | private store: Record; 24 | 25 | constructor() { 26 | this.store = {}; 27 | } 28 | 29 | delete(key: string): void { 30 | delete this.store[key]; 31 | } 32 | 33 | get(key: string): string { 34 | return this.store[key]; 35 | } 36 | 37 | put(key: string, value: string): void { 38 | this.store[key] = value; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.github/workflows/jira-issue-create.yml: -------------------------------------------------------------------------------- 1 | # Creates jira tickets for new github issues to help triage 2 | name: Jira Issue Creator 3 | 4 | on: 5 | issues: 6 | types: [opened] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | environment: Jira 12 | name: SDK Bot Jira Issue Creation 13 | steps: 14 | - name: Login 15 | uses: atlassian/gajira-login@master 16 | env: 17 | JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} 18 | JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} 19 | JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} 20 | 21 | - name: Create issue 22 | id: create 23 | uses: atlassian/gajira-create@master 24 | with: 25 | project: ${{ secrets.JIRA_PROJECT }} 26 | issuetype: Task 27 | summary: | 28 | [SDK - experiment-js-client] ${{ github.event.issue.title }} 29 | description: | 30 | ${{ github.event.issue.html_url }} 31 | fields: '{ 32 | "labels": ["experiment-js-client", "sdk-backlog-grooming", "github"] 33 | }' 34 | 35 | - name: Log created issue 36 | run: echo "Issue SKY-${{ steps.create.outputs.issue }} was created" 37 | -------------------------------------------------------------------------------- /packages/analytics-connector/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/analytics-connector", 3 | "version": "1.6.4", 4 | "description": "Connector package for Amplitude SDKs", 5 | "author": "Amplitude", 6 | "homepage": "https://github.com/amplitude/experiment-js-client", 7 | "license": "MIT", 8 | "main": "dist/analytics-connector.umd.js", 9 | "module": "dist/analytics-connector.esm.js", 10 | "es2015": "dist/analytics-connector.es2015.js", 11 | "types": "dist/types/src/index.d.ts", 12 | "publishConfig": { 13 | "access": "public" 14 | }, 15 | "repository": { 16 | "type": "git", 17 | "url": "https://github.com/amplitude/experiment-js-client.git", 18 | "directory": "packages/analytics-connector" 19 | }, 20 | "scripts": { 21 | "build": "rm -rf dist && rollup -c", 22 | "clean": "rimraf node_modules dist", 23 | "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", 24 | "test": "jest", 25 | "prepublish": "yarn build" 26 | }, 27 | "bugs": { 28 | "url": "https://github.com/amplitude/experiment-js-client/issues" 29 | }, 30 | "devDependencies": { 31 | "@types/amplitude-js": "^8.0.2", 32 | "amplitude-js": "^8.12.0" 33 | }, 34 | "files": [ 35 | "dist" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /packages/plugin-segment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-plugin-segment", 3 | "version": "0.3.7", 4 | "private": true, 5 | "description": "Experiment integration for segment analytics", 6 | "author": "Amplitude", 7 | "homepage": "https://github.com/amplitude/experiment-js-client", 8 | "license": "MIT", 9 | "main": "dist/experiment-plugin-segment-min.js", 10 | "types": "dist/types/src/index.d.ts", 11 | "publishConfig": { 12 | "access": "public" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/amplitude/experiment-js-client.git", 17 | "directory": "packages/plugin-segment" 18 | }, 19 | "scripts": { 20 | "build": "rm -rf dist && rollup -c", 21 | "clean": "rimraf node_modules dist", 22 | "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", 23 | "test": "jest", 24 | "prepublish": "yarn build" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/amplitude/experiment-js-client/issues" 28 | }, 29 | "dependencies": { 30 | "@amplitude/experiment-js-client": "^1.20.1", 31 | "@segment/analytics-next": "^1.73.0" 32 | }, 33 | "devDependencies": { 34 | "@rollup/plugin-terser": "^0.4.4", 35 | "rollup-plugin-gzip": "^3.1.0", 36 | "rollup-plugin-license": "^3.6.0" 37 | }, 38 | "files": [ 39 | "dist" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import logo from './logo.svg'; 3 | import './App.css'; 4 | import { experiment } from './index'; 5 | 6 | function App() { 7 | 8 | const [output, setOutput] = useState(''); 9 | 10 | return ( 11 |
12 |
13 | logo 14 |

Amplitude Analytics Browser Example with React

15 |

16 | Click "Fetch" to fetch variants, then "Variant" or "All" to access variants from the SDK. 17 |
18 | Open the console to view debug output from the SDK. 19 |

20 | 21 | 24 | 25 | 30 | 31 | 35 | 36 |
37 | {output} 38 |
39 |
40 |
41 | ); 42 | } 43 | 44 | export default App; 45 | -------------------------------------------------------------------------------- /packages/experiment-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-core", 3 | "version": "0.12.0", 4 | "private": false, 5 | "description": "Amplitude Experiment evaluation JavaScript implementation.", 6 | "keywords": [ 7 | "experiment", 8 | "amplitude", 9 | "evaluation" 10 | ], 11 | "author": "Amplitude", 12 | "homepage": "https://github.com/amplitude/experiment-js-client", 13 | "license": "MIT", 14 | "main": "dist/experiment-core.umd.js", 15 | "module": "dist/experiment-core.esm.js", 16 | "es2015": "dist/experiment-core.es2015.js", 17 | "types": "dist/types/src/index.d.ts", 18 | "publishConfig": { 19 | "access": "public" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/amplitude/experiment-js-client.git", 24 | "directory": "packages/experiment-core" 25 | }, 26 | "scripts": { 27 | "build": "rm -rf dist && rollup -c", 28 | "clean": "rimraf node_modules dist", 29 | "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", 30 | "test": "jest", 31 | "prepublish": "yarn build" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/amplitude/experiment-js-client/issues" 35 | }, 36 | "dependencies": { 37 | "js-base64": "^3.7.5" 38 | }, 39 | "devDependencies": { 40 | "unfetch": "^4.1.0" 41 | }, 42 | "files": [ 43 | "dist" 44 | ] 45 | } 46 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/index.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationFlag } from '@amplitude/experiment-core'; 2 | 3 | export const isNullOrUndefined = (value: unknown): boolean => { 4 | return value === null || value === undefined; 5 | }; 6 | 7 | export const isNullUndefinedOrEmpty = (value: unknown): boolean => { 8 | if (isNullOrUndefined(value)) return true; 9 | return value && Object.keys(value).length === 0; 10 | }; 11 | 12 | /** 13 | * Filters out null and undefined values from an object, returning a new object 14 | * with only defined values. This is useful for config merging where you want 15 | * defaults to take precedence over explicit null/undefined values. 16 | */ 17 | export const filterNullUndefined = (obj: T): Partial => { 18 | if (!obj || typeof obj !== 'object') { 19 | return {}; 20 | } 21 | 22 | const filtered: Partial = {}; 23 | for (const [key, value] of Object.entries(obj)) { 24 | if (!isNullOrUndefined(value)) { 25 | filtered[key as keyof T] = value as T[keyof T]; 26 | } 27 | } 28 | return filtered; 29 | }; 30 | 31 | export const isLocalEvaluationMode = ( 32 | flag: EvaluationFlag | undefined, 33 | ): boolean => { 34 | return flag?.metadata?.evaluationMode === 'local'; 35 | }; 36 | 37 | export const isRemoteEvaluationMode = ( 38 | flag: EvaluationFlag | undefined, 39 | ): boolean => { 40 | return flag?.metadata?.evaluationMode === 'remote'; 41 | }; 42 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/preview/http.ts: -------------------------------------------------------------------------------- 1 | import { safeGlobal, TimeoutError } from '@amplitude/experiment-core'; 2 | import unfetch from 'unfetch'; 3 | 4 | export interface SimpleResponse { 5 | status: number; 6 | body: string; 7 | } 8 | 9 | export interface HttpClient { 10 | request( 11 | url: string, 12 | method: string, 13 | headers: Record, 14 | data: string | null, 15 | timeout?: number, 16 | ): Promise; 17 | } 18 | 19 | const fetch = safeGlobal.fetch || unfetch; 20 | 21 | const withTimeout = ( 22 | promise: Promise, 23 | ms?: number, 24 | ): Promise => { 25 | if (!ms || ms <= 0) return promise; 26 | 27 | return Promise.race([ 28 | promise, 29 | new Promise((_, reject) => 30 | setTimeout(() => reject(new TimeoutError(`Timeout after ${ms}ms`)), ms), 31 | ), 32 | ]); 33 | }; 34 | 35 | const makeRequest = async ( 36 | url: string, 37 | method: string, 38 | headers: Record, 39 | data: string, 40 | timeout?: number, 41 | ): Promise => { 42 | const request = async () => { 43 | const response = await fetch(url, { method, headers, body: data }); 44 | return { 45 | status: response.status, 46 | body: await response.text(), 47 | }; 48 | }; 49 | 50 | return withTimeout(request(), timeout); 51 | }; 52 | 53 | export const HttpClient: HttpClient = { request: makeRequest }; 54 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This is the API Reference for the Experiment JS Client SDK. 3 | * For more details on implementing this SDK, view the documentation 4 | * [here](https://amplitude-lab.readme.io/docs/javascript-client-sdk). 5 | * @module experiment-js-client 6 | */ 7 | 8 | export { ExperimentConfig } from './config'; 9 | export { 10 | AmplitudeUserProvider, 11 | AmplitudeAnalyticsProvider, 12 | } from './providers/amplitude'; 13 | export { AmplitudeIntegrationPlugin } from './integration/amplitude'; 14 | export { 15 | Experiment, 16 | initialize, 17 | initializeWithAmplitudeAnalytics, 18 | } from './factory'; 19 | export { StubExperimentClient } from './stubClient'; 20 | export { ExperimentClient } from './experimentClient'; 21 | export { Client, FetchOptions } from './types/client'; 22 | export { 23 | ExperimentAnalyticsProvider, 24 | ExperimentAnalyticsEvent, 25 | } from './types/analytics'; 26 | export { ExperimentUserProvider } from './types/provider'; 27 | export { Source } from './types/source'; 28 | export { ExperimentUser } from './types/user'; 29 | export { Variant, Variants } from './types/variant'; 30 | export { Exposure, ExposureTrackingProvider } from './types/exposure'; 31 | export { 32 | ExperimentPlugin, 33 | IntegrationPlugin, 34 | ExperimentPluginType, 35 | ExperimentEvent, 36 | } from './types/plugin'; 37 | export { Logger, LogLevel } from './types/logger'; 38 | export { ConsoleLogger } from './logger/consoleLogger'; 39 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/stubClient.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-empty-function */ 2 | /* eslint-disable @typescript-eslint/no-unused-vars */ 3 | import { Defaults } from './config'; 4 | import { Client, FetchOptions } from './types/client'; 5 | import { ExperimentUserProvider } from './types/provider'; 6 | import { ExperimentUser } from './types/user'; 7 | import { Variant, Variants } from './types/variant'; 8 | 9 | /** 10 | * A stub {@link Client} implementation that does nothing for all methods 11 | */ 12 | export class StubExperimentClient implements Client { 13 | public getUser(): ExperimentUser { 14 | return {}; 15 | } 16 | 17 | public async start(user?: ExperimentUser): Promise { 18 | return; 19 | } 20 | 21 | public stop() {} 22 | 23 | public setUser(user: ExperimentUser): void {} 24 | 25 | public async fetch( 26 | user?: ExperimentUser, 27 | options?: FetchOptions, 28 | ): Promise { 29 | return this; 30 | } 31 | 32 | public getUserProvider(): ExperimentUserProvider { 33 | return null; 34 | } 35 | 36 | public setUserProvider( 37 | uerProvider: ExperimentUserProvider, 38 | ): StubExperimentClient { 39 | return this; 40 | } 41 | 42 | public variant(key: string, fallback?: string | Variant): Variant { 43 | return Defaults.fallbackVariant; 44 | } 45 | 46 | public all(): Variants { 47 | return {}; 48 | } 49 | 50 | public clear(): void {} 51 | 52 | public exposure(key: string): void {} 53 | } 54 | -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/topological-sort.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationFlag } from './flag'; 2 | 3 | export const topologicalSort = ( 4 | flags: Record, 5 | flagKeys?: string[], 6 | ): EvaluationFlag[] => { 7 | const available: Record = { ...flags }; 8 | const result: EvaluationFlag[] = []; 9 | const startingKeys = flagKeys || Object.keys(available); 10 | for (const flagKey of startingKeys) { 11 | const traversal = parentTraversal(flagKey, available); 12 | if (traversal) { 13 | result.push(...traversal); 14 | } 15 | } 16 | return result; 17 | }; 18 | 19 | const parentTraversal = ( 20 | flagKey: string, 21 | available: Record, 22 | path: string[] = [], 23 | ): EvaluationFlag[] | undefined => { 24 | const flag = available[flagKey]; 25 | if (!flag) { 26 | return undefined; 27 | } else if (!flag.dependencies || flag.dependencies.length === 0) { 28 | delete available[flag.key]; 29 | return [flag]; 30 | } 31 | path.push(flag.key); 32 | const result: EvaluationFlag[] = []; 33 | for (const parentKey of flag.dependencies) { 34 | if (path.some((p) => p === parentKey)) { 35 | throw Error(`Detected a cycle between flags ${path}`); 36 | } 37 | const traversal = parentTraversal(parentKey, available, path); 38 | if (traversal) { 39 | result.push(...traversal); 40 | } 41 | } 42 | result.push(flag); 43 | path.pop(); 44 | delete available[flag.key]; 45 | return result; 46 | }; 47 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import logo from './logo.svg'; 3 | import './App.css'; 4 | import { experiment } from './index'; 5 | 6 | function App() { 7 | 8 | const [output, setOutput] = useState(''); 9 | 10 | return ( 11 |
12 |
13 | logo 14 |

Amplitude Analytics Browser Example with React

15 |

16 | Click "Fetch" to fetch variants, then "Variant" or "All" to access variants from the SDK. 17 |
18 | Open the console to view debug output from the SDK. 19 |

20 | 21 | 28 | 29 | 34 | 35 | 39 | 40 |
41 | {output} 42 |
43 |
44 |
45 | ); 46 | } 47 | 48 | export default App; 49 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: 7 | - main 8 | 9 | jobs: 10 | test: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node-version: ['18', '20', '22'] 15 | os: [macos-latest, ubuntu-latest] 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - name: Checkout 20 | uses: actions/checkout@v2 21 | 22 | - name: Cache Node Modules 23 | uses: actions/cache@v4 24 | with: 25 | path: '**/node_modules' 26 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 27 | 28 | - name: Setup Node 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: ${{ matrix.node-version }} 32 | 33 | - name: Set up SSH for deploy key 34 | run: | 35 | mkdir -p ~/.ssh 36 | echo "${{ secrets.DOM_MUTATOR_ACCESS_KEY }}" > ~/.ssh/id_ed25519 37 | chmod 600 ~/.ssh/id_ed25519 38 | ssh-keyscan github.com >> ~/.ssh/known_hosts 39 | continue-on-error: true # forked repos don't have access, and this is only for experiment-tag 40 | shell: bash 41 | 42 | - name: Downgrade lerna for Node 18 43 | if: ${{ matrix.node-version == '18' }} 44 | run: | 45 | yarn cache clean 46 | yarn add lerna@6.6.2 -W --dev 47 | 48 | - name: Install 49 | run: yarn install --frozen-lockfile --force 50 | 51 | - name: Build 52 | run: npx lerna exec yarn --stream 53 | 54 | - name: Test 55 | run: yarn test 56 | -------------------------------------------------------------------------------- /docs/assets/highlight.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --light-hl-0: #000000; 3 | --dark-hl-0: #D4D4D4; 4 | --light-hl-1: #A31515; 5 | --dark-hl-1: #CE9178; 6 | --light-hl-2: #001080; 7 | --dark-hl-2: #9CDCFE; 8 | --light-hl-3: #795E26; 9 | --dark-hl-3: #DCDCAA; 10 | --light-code-background: #FFFFFF; 11 | --dark-code-background: #1E1E1E; 12 | } 13 | 14 | @media (prefers-color-scheme: light) { :root { 15 | --hl-0: var(--light-hl-0); 16 | --hl-1: var(--light-hl-1); 17 | --hl-2: var(--light-hl-2); 18 | --hl-3: var(--light-hl-3); 19 | --code-background: var(--light-code-background); 20 | } } 21 | 22 | @media (prefers-color-scheme: dark) { :root { 23 | --hl-0: var(--dark-hl-0); 24 | --hl-1: var(--dark-hl-1); 25 | --hl-2: var(--dark-hl-2); 26 | --hl-3: var(--dark-hl-3); 27 | --code-background: var(--dark-code-background); 28 | } } 29 | 30 | :root[data-theme='light'] { 31 | --hl-0: var(--light-hl-0); 32 | --hl-1: var(--light-hl-1); 33 | --hl-2: var(--light-hl-2); 34 | --hl-3: var(--light-hl-3); 35 | --code-background: var(--light-code-background); 36 | } 37 | 38 | :root[data-theme='dark'] { 39 | --hl-0: var(--dark-hl-0); 40 | --hl-1: var(--dark-hl-1); 41 | --hl-2: var(--dark-hl-2); 42 | --hl-3: var(--dark-hl-3); 43 | --code-background: var(--dark-code-background); 44 | } 45 | 46 | .hl-0 { color: var(--hl-0); } 47 | .hl-1 { color: var(--hl-1); } 48 | .hl-2 { color: var(--hl-2); } 49 | .hl-3 { color: var(--hl-3); } 50 | pre, code { background: var(--code-background); } 51 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/util/equals.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 2 | export const isEqual = (obj1: any, obj2: any): boolean => { 3 | const primitive = ['string', 'number', 'boolean', 'undefined']; 4 | const typeA = typeof obj1; 5 | const typeB = typeof obj2; 6 | if (typeA !== typeB) { 7 | return false; 8 | } 9 | for (const p of primitive) { 10 | if (p === typeA) { 11 | return obj1 === obj2; 12 | } 13 | } 14 | // check null 15 | if (obj1 == null && obj2 == null) { 16 | return true; 17 | } else if (obj1 == null || obj2 == null) { 18 | return false; 19 | } 20 | // if got here - objects 21 | if (obj1.length !== obj2.length) { 22 | return false; 23 | } 24 | //check if arrays 25 | const isArrayA = Array.isArray(obj1); 26 | const isArrayB = Array.isArray(obj2); 27 | if (isArrayA !== isArrayB) { 28 | return false; 29 | } 30 | if (isArrayA && isArrayB) { 31 | //arrays 32 | for (let i = 0; i < obj1.length; i++) { 33 | if (!isEqual(obj1[i], obj2[i])) { 34 | return false; 35 | } 36 | } 37 | } else { 38 | //objects 39 | const sorted1 = Object.keys(obj1).sort(); 40 | const sorted2 = Object.keys(obj2).sort(); 41 | if (!isEqual(sorted1, sorted2)) { 42 | return false; 43 | } 44 | //compare object values 45 | let result = true; 46 | Object.keys(obj1).forEach((key) => { 47 | if (!isEqual(obj1[key], obj2[key])) { 48 | result = false; 49 | } 50 | }); 51 | return result; 52 | } 53 | return true; 54 | }; 55 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "scripts": { 7 | "build": "lerna run build --stream", 8 | "clean": "lerna run clean --stream && rimraf node_modules", 9 | "lint": "lerna run lint --stream", 10 | "test": "lerna run test --stream" 11 | }, 12 | "devDependencies": { 13 | "@babel/core": "^7.11.1", 14 | "@babel/preset-env": "^7.11.0", 15 | "@babel/preset-typescript": "^7.10.4", 16 | "@babel/runtime": "^7.11.2", 17 | "@babel/types": "^7.11.0", 18 | "@rollup/plugin-babel": "^5.2.0", 19 | "@rollup/plugin-commonjs": "^15.0.0", 20 | "@rollup/plugin-json": "^4.1.0", 21 | "@rollup/plugin-node-resolve": "^9.0.0", 22 | "@rollup/plugin-replace": "^2.3.3", 23 | "@rollup/plugin-typescript": "^11.1.0", 24 | "@types/jest": "^29.5.0", 25 | "@types/node": "^22.0.0", 26 | "@typescript-eslint/eslint-plugin": "^5.58.0", 27 | "@typescript-eslint/parser": "^5.58.0", 28 | "eslint": "^7.7.0", 29 | "eslint-config-prettier": "^6.11.0", 30 | "eslint-plugin-import": "^2.22.1", 31 | "eslint-plugin-jest": "^27.2.1", 32 | "eslint-plugin-prettier": "^3.1.4", 33 | "jest": "^29.5.0", 34 | "jest-environment-jsdom": "^29.6.4", 35 | "lerna": "^9.0.0", 36 | "node-fetch": "^2.6.0", 37 | "prettier": "^2.0.5", 38 | "rollup": "^2.26.3", 39 | "rollup-plugin-analyzer": "^4.0.0", 40 | "ts-jest": "^29.1.0", 41 | "tslib": "^2.5.0", 42 | "typedoc": "^0.28.5", 43 | "typescript": "^5.0.4" 44 | }, 45 | "resolutions": { 46 | "@testing-library/dom": "7.26.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /packages/experiment-tag/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-tag", 3 | "version": "0.14.0", 4 | "description": "Amplitude Experiment Javascript Snippet", 5 | "author": "Amplitude", 6 | "homepage": "https://github.com/amplitude/experiment-js-client", 7 | "license": "MIT", 8 | "main": "dist/experiment-tag.umd.js", 9 | "types": "dist/types/src/index.d.ts", 10 | "publishConfig": { 11 | "access": "public" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "https://github.com/amplitude/experiment-js-client.git", 16 | "directory": "packages/experiment-tag" 17 | }, 18 | "scripts": { 19 | "build": "rm -rf dist && rollup -c && node example/build_example.js", 20 | "build-dev": "NODE_ENV=development rm -rf dist && rollup -c && node example/build_example.js", 21 | "clean": "rimraf node_modules dist", 22 | "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", 23 | "test": "jest", 24 | "prepublish": "yarn build" 25 | }, 26 | "bugs": { 27 | "url": "https://github.com/amplitude/experiment-js-client/issues" 28 | }, 29 | "dependencies": { 30 | "@amplitude/analytics-core": "^2.21.0", 31 | "@amplitude/experiment-core": "^0.12.0", 32 | "@amplitude/experiment-js-client": "^1.20.1", 33 | "dom-mutator": "git+ssh://git@github.com:amplitude/dom-mutator#ef95c531a822bce55c197a6bf5e1d86d03bc086c", 34 | "rollup-plugin-license": "^3.6.0" 35 | }, 36 | "devDependencies": { 37 | "@rollup/plugin-terser": "^0.4.4", 38 | "rollup-plugin-gzip": "^3.1.0", 39 | "rollup-plugin-license": "^3.6.0" 40 | }, 41 | "files": [ 42 | "dist" 43 | ] 44 | } 45 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/backoff.ts: -------------------------------------------------------------------------------- 1 | import { safeGlobal } from '@amplitude/experiment-core'; 2 | 3 | export class Backoff { 4 | private readonly attempts: number; 5 | private readonly min: number; 6 | private readonly max: number; 7 | private readonly scalar: number; 8 | 9 | private started = false; 10 | private done = false; 11 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 12 | private timeoutHandle: any; 13 | 14 | public constructor( 15 | attempts: number, 16 | min: number, 17 | max: number, 18 | scalar: number, 19 | ) { 20 | this.attempts = attempts; 21 | this.min = min; 22 | this.max = max; 23 | this.scalar = scalar; 24 | } 25 | 26 | public async start(fn: () => Promise): Promise { 27 | if (!this.started) { 28 | this.started = true; 29 | } else { 30 | throw Error('Backoff already started'); 31 | } 32 | await this.backoff(fn, 0, this.min); 33 | } 34 | 35 | public cancel(): void { 36 | this.done = true; 37 | clearTimeout(this.timeoutHandle); 38 | } 39 | 40 | private async backoff( 41 | fn: () => Promise, 42 | attempt: number, 43 | delay: number, 44 | ): Promise { 45 | if (this.done) { 46 | return; 47 | } 48 | this.timeoutHandle = safeGlobal.setTimeout(async () => { 49 | try { 50 | await fn(); 51 | } catch (e) { 52 | const nextAttempt = attempt + 1; 53 | if (nextAttempt < this.attempts) { 54 | const nextDelay = Math.min(delay * this.scalar, this.max); 55 | this.backoff(fn, nextAttempt, nextDelay); 56 | } 57 | } 58 | }, delay); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /packages/experiment-browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-js-client", 3 | "version": "1.20.1", 4 | "description": "Amplitude Experiment Javascript Client SDK", 5 | "keywords": [ 6 | "experiment", 7 | "amplitude" 8 | ], 9 | "author": "Amplitude", 10 | "homepage": "https://github.com/amplitude/experiment-js-client", 11 | "license": "MIT", 12 | "main": "dist/experiment.umd.js", 13 | "module": "dist/experiment.esm.js", 14 | "es2015": "dist/experiment.es2015.js", 15 | "types": "dist/types/src/index.d.ts", 16 | "publishConfig": { 17 | "access": "public" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/amplitude/experiment-js-client.git", 22 | "directory": "packages/experiment-browser" 23 | }, 24 | "scripts": { 25 | "build": "rm -rf dist && rollup -c", 26 | "clean": "rimraf node_modules dist", 27 | "docs": "typedoc", 28 | "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", 29 | "test": "jest", 30 | "version": "yarn docs && git add ../../docs", 31 | "prepublish": "yarn build" 32 | }, 33 | "bugs": { 34 | "url": "https://github.com/amplitude/experiment-js-client/issues" 35 | }, 36 | "dependencies": { 37 | "@amplitude/analytics-connector": "^1.6.4", 38 | "@amplitude/experiment-core": "^0.12.0", 39 | "@amplitude/ua-parser-js": "^0.7.31", 40 | "base64-js": "1.5.1", 41 | "unfetch": "4.1.0" 42 | }, 43 | "devDependencies": { 44 | "@rollup/plugin-terser": "^0.4.4", 45 | "@types/amplitude-js": "^8.0.2", 46 | "amplitude-js": "^8.12.0", 47 | "rollup-plugin-gzip": "^3.1.2" 48 | }, 49 | "files": [ 50 | "dist" 51 | ] 52 | } 53 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/preview/preview-api.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationFlag } from '@amplitude/experiment-core'; 2 | 3 | import { version } from '../../package.json'; 4 | import { PageObjects } from '../types'; 5 | 6 | import { HttpClient } from './http'; 7 | 8 | export interface PreviewApi { 9 | getPreviewFlagsAndPageViewObjects(): Promise<{ 10 | flags: EvaluationFlag[]; 11 | pageViewObjects: PageObjects; 12 | }>; 13 | } 14 | 15 | export class SdkPreviewApi implements PreviewApi { 16 | private readonly deploymentKey: string; 17 | private readonly serverUrl: string; 18 | private readonly httpClient: HttpClient; 19 | 20 | constructor( 21 | deploymentKey: string, 22 | serverUrl: string, 23 | httpClient: HttpClient, 24 | ) { 25 | this.deploymentKey = deploymentKey; 26 | this.serverUrl = serverUrl; 27 | this.httpClient = httpClient; 28 | } 29 | 30 | public async getPreviewFlagsAndPageViewObjects(): Promise<{ 31 | flags: EvaluationFlag[]; 32 | pageViewObjects: PageObjects; 33 | }> { 34 | const headers: Record = { 35 | Authorization: `Api-Key ${this.deploymentKey}`, 36 | }; 37 | headers['X-Amp-Exp-Library'] = `experiment-tag/${version}`; 38 | const response = await this.httpClient.request( 39 | `${this.serverUrl}/web/v1/configs`, 40 | 'GET', 41 | headers, 42 | null, 43 | 10000, 44 | ); 45 | if (response.status != 200) { 46 | throw Error(`Preview error response: status=${response.status}`); 47 | } 48 | const flags: EvaluationFlag[] = JSON.parse(response.body) 49 | .flags as EvaluationFlag[]; 50 | const pageViewObjects: PageObjects = JSON.parse(response.body) 51 | .pageObjects as PageObjects; 52 | return { flags, pageViewObjects }; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/userSessionExposureTracker.ts: -------------------------------------------------------------------------------- 1 | import { Exposure, ExposureTrackingProvider } from '../types/exposure'; 2 | import { ExperimentUser } from '../types/user'; 3 | 4 | interface Identity { 5 | userId?: string; 6 | deviceId?: string; 7 | } 8 | 9 | /** 10 | * A wrapper for an exposure tracking provider which only sends one exposure event per 11 | * flag, per variant, per user session. When the user identity (userId or deviceId) changes, 12 | * the tracking cache is reset to ensure exposures are tracked for the new user session. 13 | */ 14 | export class UserSessionExposureTracker { 15 | private readonly exposureTrackingProvider: ExposureTrackingProvider; 16 | private tracked: Record = {}; 17 | private identity: Identity = {}; 18 | 19 | constructor(exposureTrackingProvider: ExposureTrackingProvider) { 20 | this.exposureTrackingProvider = exposureTrackingProvider; 21 | } 22 | 23 | track(exposure: Exposure, user?: ExperimentUser): void { 24 | const newIdentity: Identity = { 25 | userId: user?.user_id, 26 | deviceId: user?.device_id, 27 | }; 28 | 29 | if (!this.identityEquals(this.identity, newIdentity)) { 30 | this.tracked = {}; 31 | } 32 | this.identity = newIdentity; 33 | 34 | const hasTrackedFlag = exposure.flag_key in this.tracked; 35 | const trackedVariant = this.tracked[exposure.flag_key]; 36 | if (hasTrackedFlag && trackedVariant === exposure.variant) { 37 | return; 38 | } 39 | 40 | this.tracked[exposure.flag_key] = exposure.variant; 41 | this.exposureTrackingProvider.track(exposure); 42 | } 43 | 44 | private identityEquals(id1: Identity, id2: Identity): boolean { 45 | return id1.userId === id2.userId && id1.deviceId === id2.deviceId; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | 4 | 5 |
6 |

7 | 8 | 9 | # Experiment Browser SDK 10 | 11 | ## Overview 12 | 13 | This is the JavaScript client (web browser) SDK for Experiment, Amplitude's 14 | experimentation and feature management platform. 15 | 16 | ## Getting Started 17 | 18 | Refer to the [Javascript SDK Developer Documentation](https://www.docs.developers.amplitude.com/experiment/sdks/javascript-sdk/) to get started. 19 | 20 | ## Examples 21 | 22 | This repo contains various example applications for getting familiar with the 23 | SDK usage. Each example has two applications, one with a basic example, and 24 | another with an example for integrating with the amplitude analytics SDK. 25 | 26 | * Script Tag (HTML) 27 | * [Basic Example](https://github.com/amplitude/experiment-js-client/tree/main/examples/html-app/basic) 28 | * [Amplitude Analytics SDK Integration](https://github.com/amplitude/experiment-js-client/tree/main/examples/html-app/amplitude-integration) 29 | * React 30 | * [Basic Example](https://github.com/amplitude/experiment-js-client/tree/main/examples/react-app/basic) 31 | * [Amplitude Analytics SDK Integration](https://github.com/amplitude/experiment-js-client/tree/main/examples/react-app/amplitude-integration) 32 | 33 | ## Browser Compatibility 34 | 35 | This SDK works with all major browsers and IE10+. The SDK does make use of 36 | Promises, so if you are targeting a browser that does not have native support 37 | for Promise (for example, IE), you should include a polyfill for Promise, (for 38 | example, [es6-promise](https://github.com/stefanpenner/es6-promise)). 39 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/logger/consoleLogger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console,@typescript-eslint/no-explicit-any*/ 2 | import { Logger } from '../types/logger'; 3 | 4 | /** 5 | * Default console-based logger implementation. 6 | * This logger uses the browser's console API to output log messages. 7 | * Log level filtering is handled by the AmpLogger wrapper class. 8 | * @category Logging 9 | */ 10 | export class ConsoleLogger implements Logger { 11 | /** 12 | * Log an error message 13 | * @param message The message to log 14 | * @param optionalParams Additional parameters to log 15 | */ 16 | error(message?: any, ...optionalParams: any[]): void { 17 | console.error(message, ...optionalParams); 18 | } 19 | 20 | /** 21 | * Log a warning message 22 | * @param message The message to log 23 | * @param optionalParams Additional parameters to log 24 | */ 25 | warn(message?: any, ...optionalParams: any[]): void { 26 | console.warn(message, ...optionalParams); 27 | } 28 | 29 | /** 30 | * Log an informational message 31 | * @param message The message to log 32 | * @param optionalParams Additional parameters to log 33 | */ 34 | info(message?: any, ...optionalParams: any[]): void { 35 | console.info(message, ...optionalParams); 36 | } 37 | 38 | /** 39 | * Log a debug message 40 | * @param message The message to log 41 | * @param optionalParams Additional parameters to log 42 | */ 43 | debug(message?: any, ...optionalParams: any[]): void { 44 | console.debug(message, ...optionalParams); 45 | } 46 | 47 | /** 48 | * Log a verbose message 49 | * @param message The message to log 50 | * @param optionalParams Additional parameters to log 51 | */ 52 | verbose(message?: any, ...optionalParams: any[]): void { 53 | console.debug(message, ...optionalParams); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /examples/react-app/basic/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /examples/html-app/basic/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Experiment Example - Basic 8 | 9 | 10 | 17 | 18 | 19 |

Amplitude Experiment Browser Example - Basic

20 | 21 |

Click "Fetch" to fetch variants, then "Variant" or "All" to access variants from the SDK.

22 |

Open the console to view debug output from the SDK.

23 | 24 | 27 | 28 | 29 | 40 | 41 | 45 | 46 |
47 | 48 |

49 |   
50 | 
51 | 


--------------------------------------------------------------------------------
/examples/react-app/amplitude-integration/public/index.html:
--------------------------------------------------------------------------------
 1 | 
 2 | 
 3 |   
 4 |     
 5 |     
 6 |     
 7 |     
 8 |     
12 |     
13 |     
17 |     
18 |     
27 |     React App
28 |   
29 |   
30 |     
31 |     
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ### PR Commit Title Conventions 2 | 3 | PR titles should follow [conventional commit standards](https://www.conventionalcommits.org/en/v1.0.0/). This helps automate the [release](#release) process. 4 | 5 | #### Commit Types ([related to release conditions](#release)) 6 | 7 | - **Special Case**: Any commit with `BREAKING CHANGES` in the body: Creates major release 8 | - `feat()`: New features (minimum minor release) 9 | - `fix()`: Bug fixes (minimum patch release) 10 | - `perf()`: Performance improvement 11 | - `docs()`: Documentation updates 12 | - `test()`: Test updates 13 | - `refactor()`: Code change that neither fixes a bug nor adds a feature 14 | - `style()`: Code style changes (e.g. formatting, commas, semi-colons) 15 | - `build()`: Changes that affect the build system or external dependencies (e.g. Yarn, Npm) 16 | - `ci()`: Changes to our CI configuration files and scripts 17 | - `chore()`: Other changes that don't modify src or test files 18 | - `revert()`: Revert commit 19 | 20 | ### Release [Amplitude Internal] 21 | 22 | Releases are managed by [semantic-release](https://github.com/semantic-release/semantic-release). It is a tool that will scan commits since the last release, determine the next [semantic version number](https://semver.org/), publish, and create changelogs. 23 | 24 | #### Release Conditions [Amplitude Internal] 25 | 26 | - `BREAKING CHANGES` in the body will do a major release 27 | ``` 28 | feat(cookies): Create new cookie format 29 | 30 | BREAKING CHANGES: Breaks old cookie format 31 | ``` 32 | - Else `feat` in title will do a `minor` release 33 | `feat(cookies): some changes` 34 | - Else `fix` or `perf` in title will do a `patch` release 35 | `fix: null check bug` 36 | - Else no release 37 | `docs: update website` 38 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/source.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Determines the primary source of variants before falling back. 3 | * 4 | * @category Source 5 | */ 6 | export enum Source { 7 | /** 8 | * The default way to source variants within your application. Before the 9 | * assignments are fetched, `getVariant(s)` will fallback to local storage 10 | * first, then `initialVariants` if local storage is empty. This option 11 | * effectively falls back to an assignment fetched previously. 12 | */ 13 | LocalStorage = 'localStorage', 14 | 15 | /** 16 | * This bootstrap option is used primarily for servers-side rendering using an 17 | * Experiment server SDK. This bootstrap option always prefers the config 18 | * `initialVariants` over data in local storage, even if variants are fetched 19 | * successfully and stored locally. 20 | */ 21 | InitialVariants = 'initialVariants', 22 | } 23 | 24 | /** 25 | * Indicates from which source the variant() function determines the variant 26 | * 27 | * @category Source 28 | */ 29 | export enum VariantSource { 30 | LocalStorage = 'storage', 31 | InitialVariants = 'initial', 32 | SecondaryLocalStorage = 'secondary-storage', 33 | SecondaryInitialVariants = 'secondary-initial', 34 | FallbackInline = 'fallback-inline', 35 | FallbackConfig = 'fallback-config', 36 | LocalEvaluation = 'local-evaluation', 37 | } 38 | 39 | /** 40 | * Returns true if the VariantSource is one of the fallbacks (inline or config) 41 | * 42 | * @param source a {@link VariantSource} 43 | * @returns true if source is {@link VariantSource.FallbackInline} or {@link VariantSource.FallbackConfig} 44 | */ 45 | export const isFallback = (source: VariantSource | undefined): boolean => { 46 | return ( 47 | !source || 48 | source === VariantSource.FallbackInline || 49 | source === VariantSource.FallbackConfig || 50 | source === VariantSource.SecondaryInitialVariants 51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/flag.ts: -------------------------------------------------------------------------------- 1 | export type EvaluationFlag = { 2 | key: string; 3 | variants: Record; 4 | segments: EvaluationSegment[]; 5 | dependencies?: string[]; 6 | metadata?: Record; 7 | }; 8 | 9 | export type EvaluationVariant = { 10 | key?: string; 11 | value?: unknown; 12 | payload?: unknown; 13 | metadata?: Record; 14 | }; 15 | 16 | export type EvaluationSegment = { 17 | bucket?: EvaluationBucket; 18 | conditions?: EvaluationCondition[][]; 19 | variant?: string; 20 | metadata?: Record; 21 | }; 22 | 23 | export type EvaluationBucket = { 24 | selector: string[]; 25 | salt: string; 26 | allocations: EvaluationAllocation[]; 27 | }; 28 | 29 | export type EvaluationCondition = { 30 | selector: string[]; 31 | op: string; 32 | values: string[]; 33 | }; 34 | 35 | export type EvaluationAllocation = { 36 | range: number[]; 37 | distributions: EvaluationDistribution[]; 38 | }; 39 | 40 | export type EvaluationDistribution = { 41 | variant: string; 42 | range: number[]; 43 | }; 44 | 45 | export const EvaluationOperator = { 46 | IS: 'is', 47 | IS_NOT: 'is not', 48 | CONTAINS: 'contains', 49 | DOES_NOT_CONTAIN: 'does not contain', 50 | LESS_THAN: 'less', 51 | LESS_THAN_EQUALS: 'less or equal', 52 | GREATER_THAN: 'greater', 53 | GREATER_THAN_EQUALS: 'greater or equal', 54 | VERSION_LESS_THAN: 'version less', 55 | VERSION_LESS_THAN_EQUALS: 'version less or equal', 56 | VERSION_GREATER_THAN: 'version greater', 57 | VERSION_GREATER_THAN_EQUALS: 'version greater or equal', 58 | SET_IS: 'set is', 59 | SET_IS_NOT: 'set is not', 60 | SET_CONTAINS: 'set contains', 61 | SET_DOES_NOT_CONTAIN: 'set does not contain', 62 | SET_CONTAINS_ANY: 'set contains any', 63 | SET_DOES_NOT_CONTAIN_ANY: 'set does not contain any', 64 | REGEX_MATCH: 'regex match', 65 | REGEX_DOES_NOT_MATCH: 'regex does not match', 66 | }; 67 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/sessionAnalyticsProvider.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExperimentAnalyticsEvent, 3 | ExperimentAnalyticsProvider, 4 | } from '../types/analytics'; 5 | 6 | /** 7 | * A wrapper for an analytics provider which only sends one exposure event per 8 | * flag, per variant, per session. In other words, wrapping an analytics 9 | * provider in this class will prevent the same exposure event to be sent twice 10 | * in one session. 11 | */ 12 | export class SessionAnalyticsProvider implements ExperimentAnalyticsProvider { 13 | private readonly analyticsProvider: ExperimentAnalyticsProvider; 14 | 15 | // In memory record of flagKey and variant value to in order to only set 16 | // user properties and track an exposure event once per session unless the 17 | // variant value changes 18 | private readonly setProperties: Record = {}; 19 | private readonly unsetProperties: Record = {}; 20 | 21 | constructor(analyticsProvider: ExperimentAnalyticsProvider) { 22 | this.analyticsProvider = analyticsProvider; 23 | } 24 | 25 | track(event: ExperimentAnalyticsEvent): void { 26 | if (this.setProperties[event.key] == event.variant.value) { 27 | return; 28 | } else { 29 | this.setProperties[event.key] = event.variant.value; 30 | delete this.unsetProperties[event.key]; 31 | } 32 | this.analyticsProvider.track(event); 33 | } 34 | 35 | setUserProperty?(event: ExperimentAnalyticsEvent): void { 36 | if (this.setProperties[event.key] == event.variant.value) { 37 | return; 38 | } 39 | this.analyticsProvider.setUserProperty(event); 40 | } 41 | 42 | unsetUserProperty?(event: ExperimentAnalyticsEvent): void { 43 | if (this.unsetProperties[event.key]) { 44 | return; 45 | } else { 46 | this.unsetProperties[event.key] = 'unset'; 47 | delete this.setProperties[event.key]; 48 | } 49 | this.analyticsProvider.unsetUserProperty(event); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /packages/experiment-core/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { resolve as pathResolve } from 'path'; 2 | 3 | import babel from '@rollup/plugin-babel'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import resolve from '@rollup/plugin-node-resolve'; 6 | import typescript from '@rollup/plugin-typescript'; 7 | 8 | const getCommonBrowserConfig = (target) => ({ 9 | input: 'src/index.ts', 10 | treeshake: { 11 | moduleSideEffects: 'no-external', 12 | }, 13 | plugins: [ 14 | resolve(), 15 | commonjs(), 16 | typescript({ 17 | ...(target === 'es2015' ? { target: 'es2015' } : {}), 18 | }), 19 | babel({ 20 | configFile: 21 | target === 'es2015' 22 | ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') 23 | : undefined, 24 | babelHelpers: 'bundled', 25 | exclude: ['node_modules/**'], 26 | }), 27 | ], 28 | }); 29 | 30 | const getOutputConfig = (outputOptions) => ({ 31 | output: { 32 | dir: 'dist', 33 | name: 'experiment-core', 34 | ...outputOptions, 35 | }, 36 | }); 37 | 38 | const configs = [ 39 | // legacy build for field "main" - ie8, umd, es5 syntax 40 | { 41 | ...getCommonBrowserConfig('es5'), 42 | ...getOutputConfig({ 43 | entryFileNames: 'experiment-core.umd.js', 44 | exports: 'named', 45 | format: 'umd', 46 | }), 47 | external: [], 48 | }, 49 | 50 | // tree shakable build for field "module" - ie8, esm, es5 syntax 51 | { 52 | ...getCommonBrowserConfig('es5'), 53 | ...getOutputConfig({ 54 | entryFileNames: 'experiment-core.esm.js', 55 | format: 'esm', 56 | }), 57 | external: ['unfetch'], 58 | }, 59 | 60 | // modern build for field "es2015" - not ie, esm, es2015 syntax 61 | { 62 | ...getCommonBrowserConfig('es2015'), 63 | ...getOutputConfig({ 64 | entryFileNames: 'experiment-core.es2015.js', 65 | format: 'esm', 66 | }), 67 | external: ['unfetch'], 68 | }, 69 | ]; 70 | 71 | export default configs; 72 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/logger.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Log level enumeration for controlling logging verbosity. 3 | * @category Logging 4 | */ 5 | export enum LogLevel { 6 | /** 7 | * Disable all logging 8 | */ 9 | Disable = 0, 10 | /** 11 | * Error level logging - only critical errors 12 | */ 13 | Error = 1, 14 | /** 15 | * Warning level logging - errors and warnings 16 | */ 17 | Warn = 2, 18 | /** 19 | * Info level logging - errors, warnings, and informational messages 20 | */ 21 | Info = 3, 22 | /** 23 | * Debug level logging - errors, warnings, info, and debug messages 24 | */ 25 | Debug = 4, 26 | /** 27 | * Verbose level logging - all messages including verbose details 28 | */ 29 | Verbose = 5, 30 | } 31 | 32 | /** 33 | * Logger interface that can be implemented to provide custom logging. 34 | * @category Logging 35 | */ 36 | export interface Logger { 37 | /** 38 | * Log an error message 39 | * @param message The message to log 40 | * @param optionalParams Additional parameters to log 41 | */ 42 | error(message?: any, ...optionalParams: any[]): void; 43 | 44 | /** 45 | * Log a warning message 46 | * @param message The message to log 47 | * @param optionalParams Additional parameters to log 48 | */ 49 | warn(message?: any, ...optionalParams: any[]): void; 50 | 51 | /** 52 | * Log an informational message 53 | * @param message The message to log 54 | * @param optionalParams Additional parameters to log 55 | */ 56 | info(message?: any, ...optionalParams: any[]): void; 57 | 58 | /** 59 | * Log a debug message 60 | * @param message The message to log 61 | * @param optionalParams Additional parameters to log 62 | */ 63 | debug(message?: any, ...optionalParams: any[]): void; 64 | 65 | /** 66 | * Log a verbose message 67 | * @param message The message to log 68 | * @param optionalParams Additional parameters to log 69 | */ 70 | verbose(message?: any, ...optionalParams: any[]): void; 71 | } 72 | -------------------------------------------------------------------------------- /examples/react-app/basic/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm run build` 18 | 19 | Builds the app for production to the `build` folder.\ 20 | It correctly bundles React in production mode and optimizes the build for the best performance. 21 | 22 | The build is minified and the filenames include the hashes.\ 23 | Your app is ready to be deployed! 24 | 25 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 26 | 27 | ### `npm run eject` 28 | 29 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 30 | 31 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 32 | 33 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 34 | 35 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 36 | 37 | ## Learn More 38 | 39 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 40 | 41 | To learn React, check out the [React documentation](https://reactjs.org/). 42 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm run build` 18 | 19 | Builds the app for production to the `build` folder.\ 20 | It correctly bundles React in production mode and optimizes the build for the best performance. 21 | 22 | The build is minified and the filenames include the hashes.\ 23 | Your app is ready to be deployed! 24 | 25 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 26 | 27 | ### `npm run eject` 28 | 29 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 30 | 31 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 32 | 33 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 34 | 35 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 36 | 37 | ## Learn More 38 | 39 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 40 | 41 | To learn React, check out the [React documentation](https://reactjs.org/). 42 | -------------------------------------------------------------------------------- /packages/experiment-browser/test/factory.test.ts: -------------------------------------------------------------------------------- 1 | import { Experiment } from '../src'; 2 | 3 | const API_KEY = 'client-DvWljIjiiuqLbyjqdvBaLFfEBrAvGuA3'; 4 | const OTHER_KEY = 'some-other-key'; 5 | 6 | test('Experiment.initialize, default instance name and api key, same object', async () => { 7 | const client1 = Experiment.initialize(API_KEY); 8 | const client2 = Experiment.initialize(API_KEY, { 9 | instanceName: '$default_instance', 10 | }); 11 | expect(client2).toBe(client1); 12 | }); 13 | 14 | test('Experiment.initialize, custom instance name, same object', async () => { 15 | const client1 = Experiment.initialize(API_KEY, { 16 | instanceName: 'brian', 17 | }); 18 | const client2 = Experiment.initialize(API_KEY, { 19 | instanceName: 'brian', 20 | }); 21 | expect(client2).toBe(client1); 22 | }); 23 | 24 | test('Experiment.initialize, same instance name, different api key, different object', async () => { 25 | const client1 = Experiment.initialize(API_KEY); 26 | const client2 = Experiment.initialize(OTHER_KEY); 27 | expect(client2).not.toBe(client1); 28 | }); 29 | 30 | test('Experiment.initialize, custom user provider wrapped correctly', async () => { 31 | const customUserProvider = { 32 | getUser: () => { 33 | return { user_id: 'user_id' }; 34 | }, 35 | }; 36 | const client1 = Experiment.initialize(API_KEY, { 37 | userProvider: customUserProvider, 38 | }); 39 | expect(client1.getUserProvider()).not.toStrictEqual(customUserProvider); 40 | }); 41 | 42 | test('Experiment.initialize, internal instance name suffix different clients', async () => { 43 | const client1 = Experiment.initialize(API_KEY, { 44 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 45 | // @ts-ignore 46 | internalInstanceNameSuffix: 'test1', 47 | debug: false, 48 | }); 49 | const client2 = Experiment.initialize(API_KEY, { 50 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 51 | // @ts-ignore 52 | internalInstanceNameSuffix: 'test2', 53 | debug: true, 54 | }); 55 | expect(client2).not.toBe(client1); 56 | }); 57 | -------------------------------------------------------------------------------- /packages/plugin-segment/src/snippet.ts: -------------------------------------------------------------------------------- 1 | import { safeGlobal } from './global'; 2 | 3 | /** 4 | * Copied and modified from https://github.com/segmentio/snippet/blob/master/template/snippet.js 5 | * 6 | * This function will set up proxy stubs for functions used by the segment plugin 7 | * 8 | * @param instanceKey the key for the analytics instance on the global object. 9 | */ 10 | export const snippetInstance = ( 11 | instanceKey: string | undefined = undefined, 12 | ) => { 13 | // define the key where the global analytics object will be accessible 14 | // customers can safely set this to be something else if need be 15 | const key = instanceKey || 'analytics'; 16 | 17 | // Create a queue, but don't obliterate an existing one! 18 | const analytics = (safeGlobal[key] = safeGlobal[key] || []); 19 | 20 | // Return the actual instance if the global analytics is nested in an instance. 21 | if (analytics.instance && analytics.instance.initialize) { 22 | return analytics.instance; 23 | } 24 | // If the real analytics.js is already on the page return. 25 | if (analytics.initialize) { 26 | return analytics; 27 | } 28 | const fn = 'ready'; 29 | if (analytics[fn]) { 30 | return analytics; 31 | } 32 | const factory = function (fn) { 33 | return function () { 34 | if (safeGlobal[key].initialized) { 35 | // Sometimes users assigned analytics to a variable before analytics is 36 | // done loading, resulting in a stale reference. If so, proxy any calls 37 | // to the 'real' analytics instance. 38 | // eslint-disable-next-line prefer-spread,prefer-rest-params 39 | return safeGlobal[key][fn].apply(safeGlobal[key], arguments); 40 | } 41 | // eslint-disable-next-line prefer-rest-params 42 | const args = Array.prototype.slice.call(arguments); 43 | args.unshift(fn); 44 | analytics.push(args); 45 | return analytics; 46 | }; 47 | }; 48 | // Use the predefined factory, or our own factory to stub the function. 49 | analytics[fn] = (analytics.factory || factory)(fn); 50 | return analytics; 51 | }; 52 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/storage.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | export type StorageType = 'localStorage' | 'sessionStorage'; 4 | 5 | /** 6 | * Get a JSON value from storage and parse it 7 | * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') 8 | * @param key - The key to retrieve 9 | * @returns The parsed JSON value or null if not found or invalid JSON 10 | */ 11 | export const getStorageItem = ( 12 | storageType: StorageType, 13 | key: string, 14 | ): T | null => { 15 | try { 16 | const value = getStorage(storageType)?.getItem(key); 17 | if (!value) { 18 | return null; 19 | } 20 | return JSON.parse(value) as T; 21 | } catch (error) { 22 | console.warn(`Failed to get and parse JSON from ${storageType}:`, error); 23 | return null; 24 | } 25 | }; 26 | 27 | /** 28 | * Set a JSON value in storage by stringifying it 29 | * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') 30 | * @param key - The key to store the value under 31 | * @param value - The value to stringify and store 32 | */ 33 | export const setStorageItem = ( 34 | storageType: StorageType, 35 | key: string, 36 | value: unknown, 37 | ): void => { 38 | try { 39 | const jsonString = JSON.stringify(value); 40 | getStorage(storageType)?.setItem(key, jsonString); 41 | } catch (error) { 42 | console.warn(`Failed to stringify and set JSON in ${storageType}:`, error); 43 | } 44 | }; 45 | 46 | /** 47 | * Remove a value from the specified storage type 48 | * @param storageType - The type of storage to use ('localStorage' or 'sessionStorage') 49 | * @param key - The key to remove 50 | */ 51 | export const removeStorageItem = ( 52 | storageType: StorageType, 53 | key: string, 54 | ): void => { 55 | try { 56 | getStorage(storageType)?.removeItem(key); 57 | } catch (error) { 58 | console.warn(`Failed to remove item from ${storageType}:`, error); 59 | } 60 | }; 61 | 62 | const getStorage = (storageType: StorageType): Storage | null => { 63 | const globalScope = getGlobalScope(); 64 | if (!globalScope) { 65 | return null; 66 | } 67 | return globalScope[storageType]; 68 | }; 69 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/index.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | import { DefaultWebExperimentClient } from './experiment'; 4 | import { HttpClient } from './preview/http'; 5 | import { SdkPreviewApi } from './preview/preview-api'; 6 | import { WebExperimentConfig } from './types'; 7 | import { applyAntiFlickerCss } from './util/anti-flicker'; 8 | import { isPreviewMode } from './util/url'; 9 | 10 | export const initialize = ( 11 | apiKey: string, 12 | initialFlags: string, 13 | pageObjects: string, 14 | config: WebExperimentConfig, 15 | ): void => { 16 | const shouldFetchConfigs = 17 | isPreviewMode() || getGlobalScope()?.WebExperiment.injectedByExtension; 18 | 19 | if (shouldFetchConfigs) { 20 | applyAntiFlickerCss(); 21 | fetchLatestConfigs(apiKey, config.serverZone) 22 | .then((previewState) => { 23 | const flags = JSON.stringify(previewState.flags); 24 | const objects = JSON.stringify(previewState.pageViewObjects); 25 | startClient(apiKey, flags, objects, config); 26 | }) 27 | .catch((error) => { 28 | console.warn('Failed to fetch latest configs for preview:', error); 29 | startClient(apiKey, initialFlags, pageObjects, config); 30 | }); 31 | } else { 32 | startClient(apiKey, initialFlags, pageObjects, config); 33 | } 34 | }; 35 | 36 | const startClient = ( 37 | apiKey: string, 38 | flags: string, 39 | objects: string, 40 | config: WebExperimentConfig, 41 | ): void => { 42 | DefaultWebExperimentClient.getInstance(apiKey, flags, objects, config) 43 | .start() 44 | .finally(() => { 45 | // Remove anti-flicker css if it exists 46 | document.getElementById('amp-exp-css')?.remove(); 47 | }); 48 | }; 49 | 50 | const fetchLatestConfigs = async (apiKey: string, serverZone?: string) => { 51 | const serverUrl = 52 | serverZone === 'EU' 53 | ? 'https://api.lab.eu.amplitude.com' 54 | : 'https://api.lab.amplitude.com'; 55 | const api = new SdkPreviewApi(apiKey, serverUrl, HttpClient); 56 | return api.getPreviewFlagsAndPageViewObjects(); 57 | }; 58 | 59 | export { 60 | ApplyVariantsOptions, 61 | RevertVariantsOptions, 62 | PreviewVariantsOptions, 63 | WebExperimentClient, 64 | WebExperimentConfig, 65 | } from 'types'; 66 | -------------------------------------------------------------------------------- /packages/experiment-browser/test/logger/ampLogger.test.ts: -------------------------------------------------------------------------------- 1 | import { AmpLogger } from '../../src/logger/ampLogger'; 2 | import { Logger, LogLevel } from '../../src/types/logger'; 3 | 4 | describe('AmpLogger', () => { 5 | let mockLogger: jest.Mocked; 6 | 7 | beforeEach(() => { 8 | mockLogger = { 9 | error: jest.fn(), 10 | warn: jest.fn(), 11 | info: jest.fn(), 12 | debug: jest.fn(), 13 | verbose: jest.fn(), 14 | }; 15 | }); 16 | 17 | const logLevelTests = [ 18 | { level: LogLevel.Disable, logs: [] }, 19 | { level: LogLevel.Error, logs: ['error'] }, 20 | { level: LogLevel.Warn, logs: ['error', 'warn'] }, 21 | { level: LogLevel.Info, logs: ['error', 'warn', 'info'] }, 22 | { level: LogLevel.Debug, logs: ['error', 'warn', 'info', 'debug'] }, 23 | { 24 | level: LogLevel.Verbose, 25 | logs: ['error', 'warn', 'info', 'debug', 'verbose'], 26 | }, 27 | ]; 28 | 29 | test.each(logLevelTests)( 30 | 'LogLevel.$level should log: $logs', 31 | ({ level, logs }) => { 32 | const logger = new AmpLogger(mockLogger, level); 33 | const methods = ['error', 'warn', 'info', 'debug', 'verbose'] as const; 34 | 35 | methods.forEach((method) => { 36 | logger[method](method); 37 | }); 38 | 39 | methods.forEach((method) => { 40 | if (logs.includes(method)) { 41 | expect(mockLogger[method]).toHaveBeenCalledWith(method); 42 | } else { 43 | expect(mockLogger[method]).not.toHaveBeenCalled(); 44 | } 45 | }); 46 | }, 47 | ); 48 | 49 | test('should default to LogLevel.Error when no log level is provided', () => { 50 | const logger = new AmpLogger(mockLogger); 51 | 52 | logger.error('error'); 53 | logger.warn('warn'); 54 | 55 | expect(mockLogger.error).toHaveBeenCalledWith('error'); 56 | expect(mockLogger.warn).not.toHaveBeenCalled(); 57 | }); 58 | 59 | test('should pass optional parameters to underlying logger', () => { 60 | const logger = new AmpLogger(mockLogger, LogLevel.Verbose); 61 | 62 | logger.error('error', { code: 500 }, 'extra'); 63 | logger.verbose('verbose', 1, 2, 3); 64 | 65 | expect(mockLogger.error).toHaveBeenCalledWith( 66 | 'error', 67 | { code: 500 }, 68 | 'extra', 69 | ); 70 | expect(mockLogger.verbose).toHaveBeenCalledWith('verbose', 1, 2, 3); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/experiment-core/src/api/flag-api.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64'; 2 | 3 | import { EvaluationFlag } from '../evaluation/flag'; 4 | import { HttpClient } from '../transport/http'; 5 | 6 | export type GetFlagsOptions = { 7 | libraryName: string; 8 | libraryVersion: string; 9 | evaluationMode?: string; 10 | timeoutMillis?: number; 11 | user?: Record; 12 | deliveryMethod?: string | undefined; 13 | }; 14 | 15 | export interface FlagApi { 16 | getFlags(options?: GetFlagsOptions): Promise>; 17 | } 18 | 19 | export class SdkFlagApi implements FlagApi { 20 | private readonly deploymentKey: string; 21 | private readonly serverUrl: string; 22 | private readonly httpClient: HttpClient; 23 | 24 | constructor( 25 | deploymentKey: string, 26 | serverUrl: string, 27 | httpClient: HttpClient, 28 | ) { 29 | this.deploymentKey = deploymentKey; 30 | this.serverUrl = serverUrl; 31 | this.httpClient = httpClient; 32 | } 33 | 34 | public async getFlags( 35 | options?: GetFlagsOptions, 36 | ): Promise> { 37 | const headers: Record = { 38 | Authorization: `Api-Key ${this.deploymentKey}`, 39 | }; 40 | if (options?.libraryName && options?.libraryVersion) { 41 | headers[ 42 | 'X-Amp-Exp-Library' 43 | ] = `${options.libraryName}/${options.libraryVersion}`; 44 | } 45 | if (options?.user) { 46 | headers['X-Amp-Exp-User'] = Base64.encodeURL( 47 | JSON.stringify(options.user), 48 | ); 49 | } 50 | const response = await this.httpClient.request({ 51 | requestUrl: 52 | `${this.serverUrl}/sdk/v2/flags` + 53 | (options?.deliveryMethod 54 | ? `?delivery_method=${options.deliveryMethod}` 55 | : ''), 56 | method: 'GET', 57 | headers: headers, 58 | timeoutMillis: options?.timeoutMillis, 59 | }); 60 | if (response.status != 200) { 61 | throw Error(`Flags error response: status=${response.status}`); 62 | } 63 | const flagsArray: EvaluationFlag[] = JSON.parse( 64 | response.body, 65 | ) as EvaluationFlag[]; 66 | return flagsArray.reduce( 67 | (map: Record, flag: EvaluationFlag) => { 68 | map[flag.key] = flag; 69 | return map; 70 | }, 71 | {}, 72 | ); 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/transport/http.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @packageDocumentation 3 | * @internal 4 | */ 5 | 6 | import { safeGlobal, TimeoutError } from '@amplitude/experiment-core'; 7 | import { 8 | HttpClient as CoreHttpClient, 9 | HttpRequest, 10 | HttpResponse, 11 | } from '@amplitude/experiment-core'; 12 | import unfetch from 'unfetch'; 13 | 14 | import { HttpClient, SimpleResponse } from '../types/transport'; 15 | 16 | const fetch = safeGlobal.fetch || unfetch; 17 | 18 | /* 19 | * Copied from: 20 | * https://github.com/github/fetch/issues/175#issuecomment-284787564 21 | */ 22 | const timeout = ( 23 | promise: Promise, 24 | timeoutMillis?: number, 25 | ): Promise => { 26 | // Don't timeout if timeout is null or invalid 27 | if (timeoutMillis == null || timeoutMillis <= 0) { 28 | return promise; 29 | } 30 | return new Promise(function (resolve, reject) { 31 | safeGlobal.setTimeout(function () { 32 | reject( 33 | new TimeoutError( 34 | 'Request timeout after ' + timeoutMillis + ' milliseconds', 35 | ), 36 | ); 37 | }, timeoutMillis); 38 | promise.then(resolve, reject); 39 | }); 40 | }; 41 | 42 | const _request = ( 43 | requestUrl: string, 44 | method: string, 45 | headers: Record, 46 | data: string, 47 | timeoutMillis?: number, 48 | ): Promise => { 49 | const call = async () => { 50 | const response = await fetch(requestUrl, { 51 | method: method, 52 | headers: headers, 53 | body: data, 54 | }); 55 | const simpleResponse: SimpleResponse = { 56 | status: response.status, 57 | body: await response.text(), 58 | }; 59 | return simpleResponse; 60 | }; 61 | return timeout(call(), timeoutMillis); 62 | }; 63 | 64 | /** 65 | * Wrap the exposed HttpClient in a CoreClient implementation to work with 66 | * FlagsApi and EvaluationApi. 67 | */ 68 | export class WrapperClient implements CoreHttpClient { 69 | private readonly client: HttpClient; 70 | 71 | constructor(client: HttpClient) { 72 | this.client = client; 73 | } 74 | 75 | async request(request: HttpRequest): Promise { 76 | return await this.client.request( 77 | request.requestUrl, 78 | request.method, 79 | request.headers, 80 | null, 81 | request.timeoutMillis, 82 | ); 83 | } 84 | } 85 | 86 | export const FetchHttpClient: HttpClient = { request: _request }; 87 | -------------------------------------------------------------------------------- /packages/analytics-connector/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { resolve as pathResolve } from 'path'; 2 | 3 | import babel from '@rollup/plugin-babel'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import json from '@rollup/plugin-json'; 6 | import resolve from '@rollup/plugin-node-resolve'; 7 | import replace from '@rollup/plugin-replace'; 8 | import typescript from '@rollup/plugin-typescript'; 9 | import analyze from 'rollup-plugin-analyzer'; 10 | 11 | import tsConfig from './tsconfig.json'; 12 | 13 | const getCommonBrowserConfig = (target) => ({ 14 | input: 'src/index.ts', 15 | treeshake: { 16 | moduleSideEffects: 'no-external', 17 | }, 18 | plugins: [ 19 | replace({ 20 | preventAssignment: true, 21 | BUILD_BROWSER: true, 22 | }), 23 | resolve(), 24 | json(), 25 | commonjs(), 26 | typescript({ 27 | ...(target === 'es2015' ? { target: 'es2015' } : {}), 28 | declaration: true, 29 | declarationDir: 'dist/types', 30 | include: tsConfig.include, 31 | rootDir: '.', 32 | }), 33 | babel({ 34 | configFile: 35 | target === 'es2015' 36 | ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') 37 | : undefined, 38 | babelHelpers: 'bundled', 39 | exclude: ['node_modules/**'], 40 | }), 41 | analyze({ 42 | summaryOnly: true, 43 | }), 44 | ], 45 | }); 46 | 47 | const getOutputConfig = (outputOptions) => ({ 48 | output: { 49 | dir: 'dist', 50 | name: 'Experiment', 51 | ...outputOptions, 52 | }, 53 | }); 54 | 55 | const configs = [ 56 | // legacy build for field "main" - ie8, umd, es5 syntax 57 | { 58 | ...getCommonBrowserConfig('es5'), 59 | ...getOutputConfig({ 60 | entryFileNames: 'analytics-connector.umd.js', 61 | exports: 'named', 62 | format: 'umd', 63 | }), 64 | external: [], 65 | }, 66 | 67 | // tree shakable build for field "module" - ie8, esm, es5 syntax 68 | { 69 | ...getCommonBrowserConfig('es5'), 70 | ...getOutputConfig({ 71 | entryFileNames: 'analytics-connector.esm.js', 72 | format: 'esm', 73 | }), 74 | external: [], 75 | }, 76 | 77 | // modern build for field "es2015" - not ie, esm, es2015 syntax 78 | { 79 | ...getCommonBrowserConfig('es2015'), 80 | ...getOutputConfig({ 81 | entryFileNames: 'analytics-connector.es2015.js', 82 | format: 'esm', 83 | }), 84 | external: [], 85 | }, 86 | ]; 87 | 88 | export default configs; 89 | -------------------------------------------------------------------------------- /packages/experiment-core/test/api/evaluation-api.test.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64'; 2 | 3 | import { SdkEvaluationApi } from '../../src'; 4 | 5 | const VARIANTS = { 6 | 'flag-1': { key: 'on', value: 'on' }, 7 | }; 8 | const USER = { user_id: 'test-user' }; 9 | 10 | const getMockHttpClient = () => { 11 | return { 12 | request: jest.fn().mockResolvedValue({ 13 | status: 200, 14 | body: JSON.stringify(VARIANTS), 15 | }), 16 | }; 17 | }; 18 | 19 | describe('Evaluation API', () => { 20 | it('should get variants', async () => { 21 | const mockHttpClient = getMockHttpClient(); 22 | const evaluationApi = new SdkEvaluationApi( 23 | 'test-deployment-key', 24 | 'https://server.url.amplitude.com', 25 | mockHttpClient, 26 | ); 27 | 28 | const variants = await evaluationApi.getVariants(USER); 29 | 30 | expect(variants).toEqual(VARIANTS); 31 | expect(mockHttpClient.request).toHaveBeenCalledWith({ 32 | requestUrl: 'https://server.url.amplitude.com/sdk/v2/vardata?v=0', 33 | method: 'GET', 34 | headers: { 35 | Authorization: 'Api-Key test-deployment-key', 36 | 'X-Amp-Exp-User': Base64.encodeURL(JSON.stringify(USER)), 37 | }, 38 | }); 39 | }); 40 | 41 | it('should get variants with options', async () => { 42 | const mockHttpClient = getMockHttpClient(); 43 | const evaluationApi = new SdkEvaluationApi( 44 | 'test-deployment-key', 45 | 'https://server.url.amplitude.com', 46 | mockHttpClient, 47 | ); 48 | 49 | const variants = await evaluationApi.getVariants(USER, { 50 | flagKeys: ['flag-1'], 51 | trackingOption: 'no-track', 52 | exposureTrackingOption: 'no-track', 53 | deliveryMethod: 'web', 54 | evaluationMode: 'local', 55 | timeoutMillis: 1000, 56 | }); 57 | 58 | expect(variants).toEqual(VARIANTS); 59 | expect(mockHttpClient.request).toHaveBeenCalledWith({ 60 | requestUrl: 61 | 'https://server.url.amplitude.com/sdk/v2/vardata?v=0&eval_mode=local&delivery_method=web', 62 | method: 'GET', 63 | headers: { 64 | Authorization: 'Api-Key test-deployment-key', 65 | 'X-Amp-Exp-User': Base64.encodeURL(JSON.stringify(USER)), 66 | 'X-Amp-Exp-Flag-Keys': Base64.encodeURL(JSON.stringify(['flag-1'])), 67 | 'X-Amp-Exp-Track': 'no-track', 68 | 'X-Amp-Exp-Exposure-Track': 'no-track', 69 | }, 70 | timeoutMillis: 1000, 71 | }); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/semantic-version.ts: -------------------------------------------------------------------------------- 1 | // major and minor should be non-negative numbers separated by a dot 2 | const MAJOR_MINOR_REGEX = '(\\d+)\\.(\\d+)'; 3 | 4 | // patch should be a non-negative number 5 | const PATCH_REGEX = '(\\d+)'; 6 | 7 | // prerelease is optional. If provided, it should be a hyphen followed by a 8 | // series of dot separated identifiers where an identifer can contain anything in [-0-9a-zA-Z] 9 | const PRERELEASE_REGEX = '(-(([-\\w]+\\.?)*))?'; 10 | 11 | // version pattern should be major.minor(.patchAndPreRelease) where .patchAndPreRelease is optional 12 | const VERSION_PATTERN = `^${MAJOR_MINOR_REGEX}(\\.${PATCH_REGEX}${PRERELEASE_REGEX})?$`; 13 | 14 | export class SemanticVersion { 15 | public readonly major: number; 16 | public readonly minor: number; 17 | public readonly patch: number; 18 | public readonly preRelease: string | undefined; 19 | 20 | constructor( 21 | major: number, 22 | minor: number, 23 | patch: number, 24 | preRelease: string | undefined = undefined, 25 | ) { 26 | this.major = major; 27 | this.minor = minor; 28 | this.patch = patch; 29 | this.preRelease = preRelease; 30 | } 31 | 32 | public static parse( 33 | version: string | undefined, 34 | ): SemanticVersion | undefined { 35 | if (!version) { 36 | return undefined; 37 | } 38 | const matchGroup = new RegExp(VERSION_PATTERN).exec(version); 39 | if (!matchGroup) { 40 | return undefined; 41 | } 42 | const major = Number(matchGroup[1]); 43 | const minor = Number(matchGroup[2]); 44 | if (isNaN(major) || isNaN(minor)) { 45 | return undefined; 46 | } 47 | const patch = Number(matchGroup[4]) || 0; 48 | const preRelease = matchGroup[5] || undefined; 49 | return new SemanticVersion(major, minor, patch, preRelease); 50 | } 51 | 52 | public compareTo(other: SemanticVersion): number { 53 | if (this.major > other.major) return 1; 54 | if (this.major < other.major) return -1; 55 | if (this.minor > other.minor) return 1; 56 | if (this.minor < other.minor) return -1; 57 | if (this.patch > other.patch) return 1; 58 | if (this.patch < other.patch) return -1; 59 | if (this.preRelease && !other.preRelease) return -1; 60 | if (!this.preRelease && other.preRelease) return 1; 61 | if (this.preRelease && other.preRelease) { 62 | if (this.preRelease > other.preRelease) return 1; 63 | if (this.preRelease < other.preRelease) return -1; 64 | return 0; 65 | } 66 | return 0; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/plugin-segment/src/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ExperimentEvent, 3 | ExperimentUser, 4 | IntegrationPlugin, 5 | } from '@amplitude/experiment-js-client'; 6 | 7 | import { safeGlobal } from './global'; 8 | import { snippetInstance } from './snippet'; 9 | import { Options, SegmentIntegrationPlugin } from './types/plugin'; 10 | 11 | export const segmentIntegrationPlugin: SegmentIntegrationPlugin = ( 12 | options: Options = {}, 13 | ) => { 14 | const getInstance = () => { 15 | return options.instance || snippetInstance(options.instanceKey); 16 | }; 17 | getInstance(); 18 | let ready = false; 19 | const plugin: IntegrationPlugin = { 20 | name: '@amplitude/experiment-plugin-segment', 21 | type: 'integration', 22 | setup(): Promise { 23 | const instance = getInstance(); 24 | return new Promise((resolve) => { 25 | instance.ready(() => { 26 | ready = true; 27 | resolve(); 28 | }); 29 | // If the segment SDK is installed via the @segment/analytics-next npm 30 | // package then function calls to the snippet are not respected. 31 | if (!options.instance) { 32 | const interval = safeGlobal.setInterval(() => { 33 | const instance = getInstance(); 34 | if (instance.initialized) { 35 | ready = true; 36 | safeGlobal.clearInterval(interval); 37 | resolve(); 38 | } 39 | }, 50); 40 | } 41 | }); 42 | }, 43 | getUser(): ExperimentUser { 44 | const instance = getInstance(); 45 | if (ready) { 46 | return { 47 | user_id: instance.user().id(), 48 | device_id: instance.user().anonymousId(), 49 | user_properties: instance.user().traits(), 50 | }; 51 | } 52 | const get = (key: string) => { 53 | return JSON.parse(safeGlobal.localStorage.getItem(key)) || undefined; 54 | }; 55 | return { 56 | user_id: get('ajs_user_id'), 57 | device_id: get('ajs_anonymous_id'), 58 | user_properties: get('ajs_user_traits'), 59 | }; 60 | }, 61 | track(event: ExperimentEvent): boolean { 62 | const instance = getInstance(); 63 | if (!ready) return false; 64 | instance.track(event.eventType, event.eventProperties); 65 | return true; 66 | }, 67 | }; 68 | if (options.skipSetup) { 69 | plugin.setup = undefined; 70 | } 71 | 72 | return plugin; 73 | }; 74 | 75 | safeGlobal.experimentIntegration = segmentIntegrationPlugin(); 76 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/state.ts: -------------------------------------------------------------------------------- 1 | import { safeGlobal } from '@amplitude/experiment-core'; 2 | 3 | export type AmplitudeState = { 4 | deviceId?: string; 5 | userId?: string; 6 | }; 7 | 8 | export const parseAmplitudeCookie = ( 9 | apiKey: string, 10 | newFormat = false, 11 | ): AmplitudeState | undefined => { 12 | // Get the cookie value 13 | const key = generateKey(apiKey, newFormat); 14 | let value: string | undefined = undefined; 15 | const cookies = safeGlobal.document.cookie.split('; '); 16 | for (const cookie of cookies) { 17 | const [cookieKey, cookieValue] = cookie.split('=', 2); 18 | if (cookieKey === key) { 19 | value = decodeURIComponent(cookieValue); 20 | } 21 | } 22 | if (!value) { 23 | return; 24 | } 25 | // Parse cookie value depending on format 26 | try { 27 | // New format 28 | if (newFormat) { 29 | const decoding = atob(value); 30 | return JSON.parse(decodeURIComponent(decoding)) as AmplitudeState; 31 | } 32 | // Old format 33 | const values = value.split('.'); 34 | let userId = undefined; 35 | if (values.length >= 2 && values[1]) { 36 | userId = atob(values[1]); 37 | } 38 | return { 39 | deviceId: values[0], 40 | userId, 41 | }; 42 | } catch (e) { 43 | return; 44 | } 45 | }; 46 | 47 | export const parseAmplitudeLocalStorage = ( 48 | apiKey: string, 49 | ): AmplitudeState | undefined => { 50 | const key = generateKey(apiKey, true); 51 | try { 52 | const value = safeGlobal.localStorage.getItem(key); 53 | if (!value) return; 54 | const state = JSON.parse(value); 55 | if (typeof state !== 'object') return; 56 | return state as AmplitudeState; 57 | } catch { 58 | return; 59 | } 60 | }; 61 | 62 | export const parseAmplitudeSessionStorage = ( 63 | apiKey: string, 64 | ): AmplitudeState | undefined => { 65 | const key = generateKey(apiKey, true); 66 | try { 67 | const value = safeGlobal.sessionStorage.getItem(key); 68 | if (!value) return; 69 | const state = JSON.parse(value); 70 | if (typeof state !== 'object') return; 71 | return state as AmplitudeState; 72 | } catch { 73 | return; 74 | } 75 | }; 76 | 77 | const generateKey = ( 78 | apiKey: string, 79 | newFormat: boolean, 80 | ): string | undefined => { 81 | if (newFormat) { 82 | if (apiKey?.length < 10) { 83 | return; 84 | } 85 | return `AMP_${apiKey.substring(0, 10)}`; 86 | } 87 | if (apiKey?.length < 6) { 88 | return; 89 | } 90 | return `amp_${apiKey.substring(0, 6)}`; 91 | }; 92 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/types.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationCondition } from '@amplitude/experiment-core'; 2 | import { 3 | ExperimentConfig, 4 | ExperimentUser, 5 | Variant, 6 | } from '@amplitude/experiment-js-client'; 7 | import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; 8 | 9 | import { MessageType } from './message-bus'; 10 | 11 | export type ApplyVariantsOptions = { 12 | /** 13 | * A list of flag keys to apply. 14 | */ 15 | flagKeys?: string[]; 16 | }; 17 | 18 | export type RevertVariantsOptions = { 19 | /** 20 | * A list of flag keys to revert. 21 | */ 22 | flagKeys?: string[]; 23 | }; 24 | 25 | export type PreviewVariantsOptions = { 26 | /** 27 | * A map of flag keys to variant keys to be previewed. 28 | */ 29 | keyToVariant?: Record; 30 | }; 31 | 32 | export type PreviewState = { 33 | previewFlags: Record; 34 | }; 35 | 36 | export type PageObject = { 37 | id: string; 38 | name: string; 39 | conditions?: EvaluationCondition[][]; 40 | trigger_type: MessageType; 41 | trigger_value: Record; 42 | }; 43 | 44 | export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; 45 | 46 | export interface WebExperimentConfig extends ExperimentConfig { 47 | /** 48 | * Determines whether the default implementation for handling navigation will be used 49 | * If this is set to false, for single-page applications: 50 | * 1. The variant actions applied will be based on the context (user, page URL) when the web experiment script was loaded 51 | * 2. Custom handling of navigation {@link setRedirectHandler} should be implemented such that variant actions applied on the site reflect the latest context 52 | */ 53 | useDefaultNavigationHandler?: boolean; 54 | } 55 | 56 | export const Defaults: WebExperimentConfig = { 57 | useDefaultNavigationHandler: true, 58 | }; 59 | 60 | /** 61 | * Interface for the Web Experiment client. 62 | */ 63 | 64 | export interface WebExperimentClient { 65 | start(): Promise; 66 | 67 | getExperimentClient(): ExperimentClient; 68 | 69 | applyVariants(options?: ApplyVariantsOptions): void; 70 | 71 | revertVariants(options?: RevertVariantsOptions): void; 72 | 73 | previewVariants(options: PreviewVariantsOptions): void; 74 | 75 | getVariants(): Variants; 76 | 77 | getActiveExperiments(): string[]; 78 | 79 | getActivePages(): PageObjects; 80 | 81 | setRedirectHandler(handler: (url: string) => void): void; 82 | } 83 | 84 | export type WebExperimentUser = { 85 | web_exp_id?: string; 86 | } & ExperimentUser; 87 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/logger/ampLogger.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any*/ 2 | import { Logger, LogLevel } from '../types/logger'; 3 | 4 | /** 5 | * Internal logger class that wraps a Logger implementation and handles log level filtering. 6 | * This class provides a centralized logging mechanism for the Experiment client. 7 | * @category Logging 8 | */ 9 | export class AmpLogger implements Logger { 10 | private logger: Logger; 11 | private logLevel: LogLevel; 12 | 13 | /** 14 | * Creates a new AmpLogger instance 15 | * @param logger The underlying logger implementation to use 16 | * @param logLevel The minimum log level to output. Messages below this level will be ignored. 17 | */ 18 | constructor(logger: Logger, logLevel: LogLevel = LogLevel.Error) { 19 | this.logger = logger; 20 | this.logLevel = logLevel; 21 | } 22 | 23 | /** 24 | * Log an error message 25 | * @param message The message to log 26 | * @param optionalParams Additional parameters to log 27 | */ 28 | error(message?: any, ...optionalParams: any[]): void { 29 | if (this.logLevel >= LogLevel.Error) { 30 | this.logger.error(message, ...optionalParams); 31 | } 32 | } 33 | 34 | /** 35 | * Log a warning message 36 | * @param message The message to log 37 | * @param optionalParams Additional parameters to log 38 | */ 39 | warn(message?: any, ...optionalParams: any[]): void { 40 | if (this.logLevel >= LogLevel.Warn) { 41 | this.logger.warn(message, ...optionalParams); 42 | } 43 | } 44 | 45 | /** 46 | * Log an informational message 47 | * @param message The message to log 48 | * @param optionalParams Additional parameters to log 49 | */ 50 | info(message?: any, ...optionalParams: any[]): void { 51 | if (this.logLevel >= LogLevel.Info) { 52 | this.logger.info(message, ...optionalParams); 53 | } 54 | } 55 | 56 | /** 57 | * Log a debug message 58 | * @param message The message to log 59 | * @param optionalParams Additional parameters to log 60 | */ 61 | debug(message?: any, ...optionalParams: any[]): void { 62 | if (this.logLevel >= LogLevel.Debug) { 63 | this.logger.debug(message, ...optionalParams); 64 | } 65 | } 66 | 67 | /** 68 | * Log a verbose message 69 | * @param message The message to log 70 | * @param optionalParams Additional parameters to log 71 | */ 72 | verbose(message?: any, ...optionalParams: any[]): void { 73 | if (this.logLevel >= LogLevel.Verbose) { 74 | this.logger.verbose(message, ...optionalParams); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/util/convert.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationVariant, getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | import { ExperimentUser } from '../types/user'; 4 | import { Variant } from '../types/variant'; 5 | 6 | export const convertUserToContext = ( 7 | user: ExperimentUser | undefined, 8 | ): Record => { 9 | if (!user) { 10 | return {}; 11 | } 12 | const context: Record = { user: user }; 13 | // add page context 14 | const globalScope = getGlobalScope(); 15 | if (globalScope) { 16 | context.page = { 17 | url: globalScope.location.href, 18 | }; 19 | } 20 | const groups: Record> = {}; 21 | if (!user.groups) { 22 | return context; 23 | } 24 | for (const groupType of Object.keys(user.groups)) { 25 | const groupNames = user.groups[groupType]; 26 | if (groupNames.length > 0 && groupNames[0]) { 27 | const groupName = groupNames[0]; 28 | const groupNameMap: Record = { 29 | group_name: groupName, 30 | }; 31 | // Check for group properties 32 | const groupProperties = user.group_properties?.[groupType]?.[groupName]; 33 | if (groupProperties && Object.keys(groupProperties).length > 0) { 34 | groupNameMap['group_properties'] = groupProperties; 35 | } 36 | groups[groupType] = groupNameMap; 37 | } 38 | } 39 | if (Object.keys(groups).length > 0) { 40 | context['groups'] = groups; 41 | } 42 | delete context.user['groups']; 43 | delete context.user['group_properties']; 44 | return context; 45 | }; 46 | 47 | export const convertVariant = (value: string | Variant): Variant => { 48 | if (value === null || value === undefined) { 49 | return {}; 50 | } 51 | if (typeof value == 'string') { 52 | return { 53 | key: value, 54 | value: value, 55 | }; 56 | } else { 57 | return value; 58 | } 59 | }; 60 | 61 | export const convertEvaluationVariantToVariant = ( 62 | evaluationVariant: EvaluationVariant, 63 | ): Variant => { 64 | if (!evaluationVariant) { 65 | return {}; 66 | } 67 | let experimentKey = undefined; 68 | if (evaluationVariant.metadata) { 69 | experimentKey = evaluationVariant.metadata['experimentKey']; 70 | } 71 | const variant: Variant = {}; 72 | if (evaluationVariant.key) variant.key = evaluationVariant.key; 73 | if (evaluationVariant.value) 74 | variant.value = evaluationVariant.value as string; 75 | if (evaluationVariant.payload) variant.payload = evaluationVariant.payload; 76 | if (experimentKey) variant.expKey = experimentKey; 77 | if (evaluationVariant.metadata) variant.metadata = evaluationVariant.metadata; 78 | return variant; 79 | }; 80 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/campaign.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Campaign, 3 | CampaignParser, 4 | CookieStorage, 5 | getStorageKey, 6 | MKTG, 7 | } from '@amplitude/analytics-core'; 8 | import { UTMParameters } from '@amplitude/analytics-core/lib/esm/types/campaign'; 9 | import { type ExperimentUser } from '@amplitude/experiment-js-client'; 10 | 11 | import { getStorageItem, setStorageItem } from './storage'; 12 | 13 | /** 14 | * Enriches the user object's userProperties with UTM parameters based on priority: 15 | * 1. URL params (highest priority) 16 | * 2. experiment-tag persisted props (medium priority) 17 | * 3. analytics-browser persisted props (lowest priority, if using default Amplitude Analytics integration) 18 | */ 19 | export async function enrichUserWithCampaignData( 20 | apiKey: string, 21 | user: ExperimentUser, 22 | ): Promise { 23 | const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`; 24 | const [currentCampaign, persistedAmplitudeCampaign] = await fetchCampaignData( 25 | apiKey, 26 | ); 27 | const persistedExperimentCampaign = getStorageItem( 28 | 'localStorage', 29 | experimentStorageKey, 30 | ); 31 | 32 | // Filter out undefined values and non-UTM parameters 33 | const utmParams: Partial = {}; 34 | const allCampaigns = [ 35 | persistedAmplitudeCampaign, // lowest priority 36 | persistedExperimentCampaign, // medium prioirty 37 | currentCampaign, // highest priority 38 | ]; 39 | 40 | for (const campaign of allCampaigns) { 41 | if (campaign) { 42 | for (const [key, value] of Object.entries(campaign)) { 43 | if (key.startsWith('utm_') && value !== undefined) { 44 | utmParams[key] = value; 45 | } 46 | } 47 | } 48 | } 49 | 50 | if (Object.keys(utmParams).length > 0) { 51 | persistUrlParams(apiKey, utmParams); 52 | return { 53 | ...user, 54 | persisted_url_param: utmParams, 55 | }; 56 | } 57 | return user; 58 | } 59 | 60 | /** 61 | * Persists UTM parameters from the current URL to experiment-tag storage 62 | */ 63 | export function persistUrlParams( 64 | apiKey: string, 65 | campaign: Record, 66 | ): void { 67 | const experimentStorageKey = `EXP_${MKTG}_${apiKey.substring(0, 10)}`; 68 | setStorageItem('localStorage', experimentStorageKey, campaign); 69 | } 70 | 71 | async function fetchCampaignData( 72 | apiKey: string, 73 | ): Promise<[Campaign, Campaign | undefined]> { 74 | const storage = new CookieStorage(); 75 | const storageKey = getStorageKey(apiKey, MKTG); 76 | const currentCampaign = await new CampaignParser().parse(); 77 | const previousCampaign = await storage.get(storageKey); 78 | return [currentCampaign, previousCampaign]; 79 | } 80 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/message-bus.ts: -------------------------------------------------------------------------------- 1 | export interface EventProperties { 2 | [k: string]: unknown; 3 | } 4 | 5 | export interface AnalyticsEvent { 6 | event_type: string; 7 | event_properties: EventProperties; 8 | } 9 | 10 | type Subscriber = { 11 | identifier?: string; 12 | callback: (payload: MessagePayloads[T]) => void; 13 | }; 14 | 15 | export type ElementAppearedPayload = { mutationList: MutationRecord[] }; 16 | export type AnalyticsEventPayload = AnalyticsEvent; 17 | export type ManualTriggerPayload = { name: string }; 18 | export type UrlChangePayload = { updateActivePages?: boolean }; 19 | 20 | export type MessagePayloads = { 21 | element_appeared: ElementAppearedPayload; 22 | url_change: UrlChangePayload; 23 | analytics_event: AnalyticsEventPayload; 24 | manual: ManualTriggerPayload; 25 | }; 26 | 27 | export type MessageType = keyof MessagePayloads; 28 | 29 | interface SubscriberGroup { 30 | subscribers: Subscriber[]; 31 | callback?: (payload: MessagePayloads[T]) => void; 32 | } 33 | 34 | export class MessageBus { 35 | private messageToSubscriberGroup: Map>; 36 | private subscriberGroupCallback: Map void>; 37 | 38 | constructor() { 39 | this.messageToSubscriberGroup = new Map(); 40 | this.subscriberGroupCallback = new Map(); 41 | } 42 | 43 | subscribe( 44 | messageType: T, 45 | listener: Subscriber['callback'], 46 | listenerId: string | undefined = undefined, 47 | groupCallback?: (payload: MessagePayloads[T]) => void, 48 | ): void { 49 | // this happens upon init, page objects "listen" to triggers relevant to them 50 | let entry = this.messageToSubscriberGroup.get( 51 | messageType, 52 | ) as SubscriberGroup; 53 | if (!entry) { 54 | entry = { subscribers: [] }; 55 | this.messageToSubscriberGroup.set(messageType, entry); 56 | groupCallback && 57 | this.subscriberGroupCallback.set(messageType, groupCallback); 58 | } 59 | 60 | const subscriber: Subscriber = { 61 | identifier: listenerId, 62 | callback: listener, 63 | }; 64 | entry.subscribers.push(subscriber); 65 | } 66 | 67 | publish( 68 | messageType: T, 69 | payload?: MessagePayloads[T], 70 | ): void { 71 | const entry = this.messageToSubscriberGroup.get( 72 | messageType, 73 | ) as SubscriberGroup; 74 | if (!entry) return; 75 | 76 | entry.subscribers.forEach((subscriber) => { 77 | payload = payload || ({} as MessagePayloads[T]); 78 | subscriber.callback(payload); 79 | }); 80 | this.subscriberGroupCallback.get(messageType)?.(payload); 81 | } 82 | 83 | unsubscribeAll(): void { 84 | this.messageToSubscriberGroup = new Map(); 85 | this.subscriberGroupCallback = new Map(); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /packages/plugin-segment/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { resolve as pathResolve } from 'path'; 2 | import { join } from 'path'; 3 | 4 | import babel from '@rollup/plugin-babel'; 5 | import commonjs from '@rollup/plugin-commonjs'; 6 | import json from '@rollup/plugin-json'; 7 | import resolve from '@rollup/plugin-node-resolve'; 8 | import replace from '@rollup/plugin-replace'; 9 | import terser from '@rollup/plugin-terser'; 10 | import typescript from '@rollup/plugin-typescript'; 11 | import analyze from 'rollup-plugin-analyzer'; 12 | import gzip from 'rollup-plugin-gzip'; 13 | import license from 'rollup-plugin-license'; 14 | 15 | import * as packageJson from './package.json'; 16 | import tsConfig from './tsconfig.json'; 17 | 18 | const getCommonBrowserConfig = (target) => ({ 19 | input: 'src/index.ts', 20 | treeshake: { 21 | moduleSideEffects: 'no-external', 22 | }, 23 | plugins: [ 24 | replace({ 25 | preventAssignment: true, 26 | BUILD_BROWSER: true, 27 | }), 28 | resolve(), 29 | json(), 30 | commonjs(), 31 | typescript({ 32 | ...(target === 'es2015' ? { target: 'es2015' } : {}), 33 | declaration: true, 34 | declarationDir: 'dist/types', 35 | include: tsConfig.include, 36 | rootDir: '.', 37 | }), 38 | babel({ 39 | configFile: 40 | target === 'es2015' 41 | ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') 42 | : undefined, 43 | babelHelpers: 'bundled', 44 | exclude: ['node_modules/**'], 45 | }), 46 | analyze({ 47 | summaryOnly: true, 48 | }), 49 | license({ 50 | thirdParty: { 51 | output: join(__dirname, 'dist', 'LICENSES'), 52 | }, 53 | }), 54 | ], 55 | }); 56 | 57 | const getOutputConfig = (outputOptions) => ({ 58 | output: { 59 | dir: 'dist', 60 | name: 'Experiment', 61 | banner: `/* ${packageJson.name} v${packageJson.version} - For license info see https://unpkg.com/@amplitude/experiment-plugin-segment@${packageJson.version}/files/LICENSE */`, 62 | ...outputOptions, 63 | }, 64 | }); 65 | 66 | const config = getCommonBrowserConfig('es5'); 67 | const configs = [ 68 | { 69 | ...config, 70 | ...getOutputConfig({ 71 | entryFileNames: 'experiment-plugin-segment-min.js', 72 | exports: 'named', 73 | format: 'umd', 74 | }), 75 | plugins: [ 76 | ...config.plugins, 77 | terser({ 78 | format: { 79 | // Don't remove semver comment 80 | comments: 81 | /@amplitude\/.* v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, 82 | }, 83 | }), // Apply terser plugin for minification 84 | gzip(), // Add gzip plugin to create .gz files 85 | ], 86 | external: [], 87 | }, 88 | ]; 89 | 90 | export default configs; 91 | -------------------------------------------------------------------------------- /packages/experiment-core/src/evaluation/murmur3.ts: -------------------------------------------------------------------------------- 1 | import { stringToUtf8ByteArray } from './utils'; 2 | 3 | const C1_32 = -0x3361d2af; 4 | const C2_32 = 0x1b873593; 5 | const R1_32 = 15; 6 | const R2_32 = 13; 7 | const M_32 = 5; 8 | const N_32 = -0x19ab949c; 9 | 10 | export const hash32x86 = (input: string, seed = 0): number => { 11 | const data = stringToUtf8ByteArray(input); 12 | const length = data.length; 13 | const nBlocks = length >> 2; 14 | let hash = seed; 15 | 16 | // body 17 | for (let i = 0; i < nBlocks; i++) { 18 | const index = i << 2; 19 | const k = readIntLe(data, index); 20 | hash = mix32(k, hash); 21 | } 22 | 23 | // tail 24 | const index = nBlocks << 2; 25 | let k1 = 0; 26 | switch (length - index) { 27 | case 3: 28 | k1 ^= data[index + 2] << 16; 29 | k1 ^= data[index + 1] << 8; 30 | k1 ^= data[index]; 31 | k1 = Math.imul(k1, C1_32); 32 | k1 = rotateLeft(k1, R1_32); 33 | k1 = Math.imul(k1, C2_32); 34 | hash ^= k1; 35 | break; 36 | case 2: 37 | k1 ^= data[index + 1] << 8; 38 | k1 ^= data[index]; 39 | k1 = Math.imul(k1, C1_32); 40 | k1 = rotateLeft(k1, R1_32); 41 | k1 = Math.imul(k1, C2_32); 42 | hash ^= k1; 43 | break; 44 | case 1: 45 | k1 ^= data[index]; 46 | k1 = Math.imul(k1, C1_32); 47 | k1 = rotateLeft(k1, R1_32); 48 | k1 = Math.imul(k1, C2_32); 49 | hash ^= k1; 50 | break; 51 | } 52 | hash ^= length; 53 | return fmix32(hash) >>> 0; 54 | }; 55 | 56 | export const mix32 = (k: number, hash: number): number => { 57 | let kResult = k; 58 | let hashResult = hash; 59 | kResult = Math.imul(kResult, C1_32); 60 | kResult = rotateLeft(kResult, R1_32); 61 | kResult = Math.imul(kResult, C2_32); 62 | hashResult ^= kResult; 63 | hashResult = rotateLeft(hashResult, R2_32); 64 | hashResult = Math.imul(hashResult, M_32); 65 | return (hashResult + N_32) | 0; 66 | }; 67 | 68 | export const fmix32 = (hash: number): number => { 69 | let hashResult = hash; 70 | hashResult ^= hashResult >>> 16; 71 | hashResult = Math.imul(hashResult, -0x7a143595); 72 | hashResult ^= hashResult >>> 13; 73 | hashResult = Math.imul(hashResult, -0x3d4d51cb); 74 | hashResult ^= hashResult >>> 16; 75 | return hashResult; 76 | }; 77 | 78 | export const rotateLeft = (x: number, n: number, width = 32): number => { 79 | if (n > width) n = n % width; 80 | const mask = (0xffffffff << (width - n)) >>> 0; 81 | const r = (((x & mask) >>> 0) >>> (width - n)) >>> 0; 82 | return ((x << n) | r) >>> 0; 83 | }; 84 | 85 | export const readIntLe = (data: Uint8Array, index = 0): number => { 86 | const n = 87 | (data[index] << 24) | 88 | (data[index + 1] << 16) | 89 | (data[index + 2] << 8) | 90 | data[index + 3]; 91 | return reverseBytes(n); 92 | }; 93 | 94 | export const reverseBytes = (n: number): number => { 95 | return ( 96 | ((n & -0x1000000) >>> 24) | 97 | ((n & 0x00ff0000) >>> 8) | 98 | ((n & 0x0000ff00) << 8) | 99 | ((n & 0x000000ff) << 24) 100 | ); 101 | }; 102 | -------------------------------------------------------------------------------- /packages/experiment-browser/test/storage.test.ts: -------------------------------------------------------------------------------- 1 | import { transformVariantFromStorage } from '../src/storage/cache'; 2 | 3 | describe('transformVariantFromStorage', () => { 4 | test('v0 variant transformation', () => { 5 | const storedVariant = 'on'; 6 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 7 | key: 'on', 8 | value: 'on', 9 | }); 10 | }); 11 | test('v1 variant transformation', () => { 12 | const storedVariant = { 13 | value: 'on', 14 | }; 15 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 16 | key: 'on', 17 | value: 'on', 18 | }); 19 | }); 20 | test('v1 variant transformation, with payload', () => { 21 | const storedVariant = { 22 | value: 'on', 23 | payload: { k: 'v' }, 24 | }; 25 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 26 | key: 'on', 27 | value: 'on', 28 | payload: { k: 'v' }, 29 | }); 30 | }); 31 | test('v1 variant transformation, with payload and experiment key', () => { 32 | const storedVariant = { 33 | value: 'on', 34 | payload: { k: 'v' }, 35 | expKey: 'exp-1', 36 | }; 37 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 38 | key: 'on', 39 | value: 'on', 40 | payload: { k: 'v' }, 41 | expKey: 'exp-1', 42 | metadata: { 43 | experimentKey: 'exp-1', 44 | }, 45 | }); 46 | }); 47 | test('v2 variant transformation', () => { 48 | const storedVariant = { 49 | key: 'treatment', 50 | value: 'on', 51 | }; 52 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 53 | key: 'treatment', 54 | value: 'on', 55 | }); 56 | }); 57 | test('v2 variant transformation, with payload', () => { 58 | const storedVariant = { 59 | key: 'treatment', 60 | value: 'on', 61 | payload: { k: 'v' }, 62 | }; 63 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 64 | key: 'treatment', 65 | value: 'on', 66 | payload: { k: 'v' }, 67 | }); 68 | }); 69 | test('v2 variant transformation, with payload and experiment key', () => { 70 | const storedVariant = { 71 | key: 'treatment', 72 | value: 'on', 73 | payload: { k: 'v' }, 74 | expKey: 'exp-1', 75 | }; 76 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 77 | key: 'treatment', 78 | value: 'on', 79 | payload: { k: 'v' }, 80 | expKey: 'exp-1', 81 | metadata: { 82 | experimentKey: 'exp-1', 83 | }, 84 | }); 85 | }); 86 | test('v2 variant transformation, with payload and experiment key metadata', () => { 87 | const storedVariant = { 88 | key: 'treatment', 89 | value: 'on', 90 | payload: { k: 'v' }, 91 | metadata: { 92 | experimentKey: 'exp-1', 93 | }, 94 | }; 95 | expect(transformVariantFromStorage(storedVariant)).toEqual({ 96 | key: 'treatment', 97 | value: 'on', 98 | payload: { k: 'v' }, 99 | expKey: 'exp-1', 100 | metadata: { 101 | experimentKey: 'exp-1', 102 | }, 103 | }); 104 | }); 105 | }); 106 | -------------------------------------------------------------------------------- /packages/experiment-tag/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { execSync } from 'child_process'; 2 | import { join, resolve as pathResolve } from 'path'; 3 | 4 | import tsConfig from '@amplitude/experiment-js-client/tsconfig.json'; 5 | import babel from '@rollup/plugin-babel'; 6 | import commonjs from '@rollup/plugin-commonjs'; 7 | import json from '@rollup/plugin-json'; 8 | import resolve from '@rollup/plugin-node-resolve'; 9 | import terser from '@rollup/plugin-terser'; 10 | import typescript from '@rollup/plugin-typescript'; 11 | import analyze from 'rollup-plugin-analyzer'; 12 | import gzip from 'rollup-plugin-gzip'; 13 | import license from 'rollup-plugin-license'; 14 | 15 | import * as packageJson from './package.json'; 16 | 17 | let branchName = ''; 18 | try { 19 | const fullBranch = execSync('git rev-parse --abbrev-ref HEAD', { 20 | encoding: 'utf8', 21 | }).trim(); 22 | const cleanBranch = fullBranch.replace(/^web\//, ''); 23 | branchName = cleanBranch !== 'main' ? cleanBranch : ''; 24 | } catch (error) { 25 | console.warn('Unable to get git branch name:', error.message); 26 | } 27 | 28 | const getCommonBrowserConfig = (target) => ({ 29 | input: 'src/index.ts', 30 | treeshake: { 31 | moduleSideEffects: 'no-external', 32 | }, 33 | plugins: [ 34 | resolve(), 35 | json(), 36 | commonjs(), 37 | typescript({ 38 | ...(target === 'es2015' 39 | ? { target: 'es2015', downlevelIteration: true } 40 | : { downlevelIteration: true }), 41 | declaration: true, 42 | declarationDir: 'dist/types', 43 | include: tsConfig.include, 44 | rootDir: '.', 45 | }), 46 | babel({ 47 | configFile: 48 | target === 'es2015' 49 | ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') 50 | : undefined, 51 | babelHelpers: 'bundled', 52 | exclude: ['node_modules/**'], 53 | }), 54 | analyze({ 55 | summaryOnly: true, 56 | }), 57 | license({ 58 | thirdParty: { 59 | output: join(__dirname, 'dist', 'LICENSES'), 60 | }, 61 | }), 62 | ], 63 | }); 64 | 65 | const getOutputConfig = (outputOptions) => ({ 66 | output: { 67 | dir: 'dist', 68 | name: 'WebExperiment', 69 | banner: `/* ${packageJson.name} v${packageJson.version}${ 70 | branchName ? ` (${branchName})` : '' 71 | } - For license info see https://unpkg.com/@amplitude/experiment-tag@${ 72 | packageJson.version 73 | }/files/LICENSE */`, 74 | ...outputOptions, 75 | }, 76 | }); 77 | 78 | const config = getCommonBrowserConfig('es5'); 79 | const configs = [ 80 | { 81 | ...config, 82 | ...getOutputConfig({ 83 | entryFileNames: 'experiment-tag-min.js', 84 | format: 'iife', 85 | }), 86 | plugins: [ 87 | ...config.plugins, 88 | terser({ 89 | format: { 90 | // Don't remove semver comment 91 | comments: 92 | /@amplitude\/.* v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, 93 | }, 94 | }), 95 | gzip(), 96 | ], 97 | external: [], 98 | }, 99 | ]; 100 | 101 | export default configs; 102 | -------------------------------------------------------------------------------- /.github/workflows/semantic-pr.yml: -------------------------------------------------------------------------------- 1 | name: Semantic PR Check 2 | 3 | on: 4 | pull_request: 5 | types: [opened, synchronize, edited] 6 | 7 | jobs: 8 | pr-title-check: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: PR title is valid 12 | if: > 13 | startsWith(github.event.pull_request.title, 'feat:') || startsWith(github.event.pull_request.title, 'feat(') || 14 | startsWith(github.event.pull_request.title, 'fix:') || startsWith(github.event.pull_request.title, 'fix(') || 15 | startsWith(github.event.pull_request.title, 'perf:') || startsWith(github.event.pull_request.title, 'perf(') || 16 | startsWith(github.event.pull_request.title, 'docs:') || startsWith(github.event.pull_request.title, 'docs(') || 17 | startsWith(github.event.pull_request.title, 'test:') || startsWith(github.event.pull_request.title, 'test(') || 18 | startsWith(github.event.pull_request.title, 'refactor:') || startsWith(github.event.pull_request.title, 'refactor(') || 19 | startsWith(github.event.pull_request.title, 'style:') || startsWith(github.event.pull_request.title, 'style(') || 20 | startsWith(github.event.pull_request.title, 'build:') || startsWith(github.event.pull_request.title, 'build(') || 21 | startsWith(github.event.pull_request.title, 'ci:') || startsWith(github.event.pull_request.title, 'ci(') || 22 | startsWith(github.event.pull_request.title, 'chore:') || startsWith(github.event.pull_request.title, 'chore(') || 23 | startsWith(github.event.pull_request.title, 'revert:') || startsWith(github.event.pull_request.title, 'revert(') 24 | run: | 25 | echo 'Title checks passed' 26 | 27 | - name: PR title is invalid 28 | if: > 29 | !startsWith(github.event.pull_request.title, 'feat:') && !startsWith(github.event.pull_request.title, 'feat(') && 30 | !startsWith(github.event.pull_request.title, 'fix:') && !startsWith(github.event.pull_request.title, 'fix(') && 31 | !startsWith(github.event.pull_request.title, 'perf:') && !startsWith(github.event.pull_request.title, 'perf(') && 32 | !startsWith(github.event.pull_request.title, 'docs:') && !startsWith(github.event.pull_request.title, 'docs(') && 33 | !startsWith(github.event.pull_request.title, 'test:') && !startsWith(github.event.pull_request.title, 'test(') && 34 | !startsWith(github.event.pull_request.title, 'refactor:') && !startsWith(github.event.pull_request.title, 'refactor(') && 35 | !startsWith(github.event.pull_request.title, 'style:') && !startsWith(github.event.pull_request.title, 'style(') && 36 | !startsWith(github.event.pull_request.title, 'build:') && !startsWith(github.event.pull_request.title, 'build(') && 37 | !startsWith(github.event.pull_request.title, 'ci:') && !startsWith(github.event.pull_request.title, 'ci(') && 38 | !startsWith(github.event.pull_request.title, 'chore:') && !startsWith(github.event.pull_request.title, 'chore(') && 39 | !startsWith(github.event.pull_request.title, 'revert:') && !startsWith(github.event.pull_request.title, 'revert(') 40 | run: | 41 | echo 'Pull request title is not valid. Please check github.com/amplitude/Amplitude-JavaScript/blob/main/CONTRIBUTING.md#pr-commit-title-conventions' 42 | exit 1 43 | -------------------------------------------------------------------------------- /packages/experiment-core/src/api/evaluation-api.ts: -------------------------------------------------------------------------------- 1 | import { Base64 } from 'js-base64'; 2 | 3 | import { FetchError } from '../evaluation/error'; 4 | import { EvaluationVariant } from '../evaluation/flag'; 5 | import { HttpClient } from '../transport/http'; 6 | 7 | export type EvaluationMode = 'remote' | 'local'; 8 | export type DeliveryMethod = 'feature' | 'web'; 9 | export type TrackingOption = 'track' | 'no-track' | 'read-only'; // For tracking assignment events 10 | export type ExposureTrackingOption = 'track' | 'no-track'; // For tracking exposure events 11 | 12 | export type GetVariantsOptions = { 13 | flagKeys?: string[]; 14 | /** 15 | * Enables or disables tracking of assignment events when fetching variants. 16 | * If not set, the default is to track assignment events. 17 | */ 18 | trackingOption?: TrackingOption; 19 | /** 20 | * Enables or disables tracking of exposure events when fetching variants. 21 | * If not set, the default is to not track exposure events. 22 | */ 23 | exposureTrackingOption?: ExposureTrackingOption; 24 | deliveryMethod?: DeliveryMethod; 25 | evaluationMode?: EvaluationMode; 26 | timeoutMillis?: number; 27 | }; 28 | 29 | export interface EvaluationApi { 30 | getVariants( 31 | user: Record, 32 | options?: GetVariantsOptions, 33 | ): Promise>; 34 | } 35 | 36 | export class SdkEvaluationApi implements EvaluationApi { 37 | private readonly deploymentKey: string; 38 | private readonly serverUrl: string; 39 | private readonly httpClient: HttpClient; 40 | 41 | constructor( 42 | deploymentKey: string, 43 | serverUrl: string, 44 | httpClient: HttpClient, 45 | ) { 46 | this.deploymentKey = deploymentKey; 47 | this.serverUrl = serverUrl; 48 | this.httpClient = httpClient; 49 | } 50 | 51 | async getVariants( 52 | user: Record, 53 | options?: GetVariantsOptions, 54 | ): Promise> { 55 | const userJsonBase64 = Base64.encodeURL(JSON.stringify(user)); 56 | const headers: Record = { 57 | Authorization: `Api-Key ${this.deploymentKey}`, 58 | 'X-Amp-Exp-User': userJsonBase64, 59 | }; 60 | if (options?.flagKeys) { 61 | headers['X-Amp-Exp-Flag-Keys'] = Base64.encodeURL( 62 | JSON.stringify(options.flagKeys), 63 | ); 64 | } 65 | // For tracking assignment events 66 | if (options?.trackingOption) { 67 | headers['X-Amp-Exp-Track'] = options.trackingOption; 68 | } 69 | // For tracking exposure events 70 | if (options?.exposureTrackingOption) { 71 | headers['X-Amp-Exp-Exposure-Track'] = options.exposureTrackingOption; 72 | } 73 | const url = new URL(`${this.serverUrl}/sdk/v2/vardata?v=0`); 74 | if (options?.evaluationMode) { 75 | url.searchParams.append('eval_mode', options?.evaluationMode); 76 | } 77 | if (options?.deliveryMethod) { 78 | url.searchParams.append('delivery_method', options?.deliveryMethod); 79 | } 80 | const response = await this.httpClient.request({ 81 | requestUrl: url.toString(), 82 | method: 'GET', 83 | headers: headers, 84 | timeoutMillis: options?.timeoutMillis, 85 | }); 86 | if (response.status != 200) { 87 | throw new FetchError( 88 | response.status, 89 | `Fetch error response: status=${response.status}`, 90 | ); 91 | } 92 | return JSON.parse(response.body); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util/url.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | import { PREVIEW_MODE_PARAM, PREVIEW_MODE_SESSION_KEY } from '../experiment'; 4 | import { PreviewState } from '../types'; 5 | 6 | import { getStorageItem } from './storage'; 7 | 8 | export const getUrlParams = (): Record => { 9 | const globalScope = getGlobalScope(); 10 | const searchParams = new URLSearchParams(globalScope?.location.search); 11 | const params: Record = {}; 12 | for (const [key, value] of searchParams) { 13 | params[key] = value; 14 | } 15 | return params; 16 | }; 17 | 18 | export const urlWithoutParamsAndAnchor = (url: string): string => { 19 | if (!url) { 20 | return ''; 21 | } 22 | const urlObj = new URL(url); 23 | urlObj.search = ''; 24 | urlObj.hash = ''; 25 | return urlObj.toString(); 26 | }; 27 | 28 | export const removeQueryParams = ( 29 | url: string, 30 | paramsToRemove: string[], 31 | ): string => { 32 | const hashIndex = url.indexOf('#'); 33 | const hasHashPath = 34 | hashIndex !== -1 && url.substring(hashIndex + 1).startsWith('/'); 35 | 36 | if (!hasHashPath) { 37 | const urlObj = new URL(url); 38 | for (const param of paramsToRemove) { 39 | urlObj.searchParams.delete(param); 40 | } 41 | return urlObj.toString(); 42 | } 43 | 44 | // Hash-based routing handling 45 | const [urlWithoutHash, hash] = url.split('#'); 46 | const hashObj = new URL(`http://dummy.com/${hash}`); 47 | 48 | for (const param of paramsToRemove) { 49 | hashObj.searchParams.delete(param); 50 | } 51 | 52 | const newHash = hashObj.pathname.substring(1) + hashObj.search; 53 | return `${urlWithoutHash}#${newHash}`; 54 | }; 55 | 56 | export const matchesUrl = (urlArray: string[], urlString: string): boolean => { 57 | urlString = urlString.replace(/\/$/, ''); 58 | 59 | return urlArray.some((url) => { 60 | url = url.replace(/\/$/, ''); // remove trailing slash 61 | url = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape url for regex 62 | url = url.replace(/\\\*/, '.*'); // replace escaped * with .* 63 | const regex = new RegExp(`^${url}$`); 64 | // Check regex match with and without trailing slash. For example, 65 | // `https://example.com/*` would not match `https://example.com` without 66 | // this addition. 67 | return regex.test(urlString) || regex.test(urlString + '/'); 68 | }); 69 | }; 70 | 71 | export const concatenateQueryParamsOf = ( 72 | currentUrl: string, 73 | redirectUrl: string, 74 | ): string => { 75 | const globalUrlObj = new URL(currentUrl); 76 | const redirectUrlObj = new URL(redirectUrl); 77 | const resultUrlObj = new URL(redirectUrl); 78 | 79 | globalUrlObj.searchParams.forEach((value, key) => { 80 | if (!redirectUrlObj.searchParams.has(key)) { 81 | resultUrlObj.searchParams.append(key, value); 82 | } 83 | }); 84 | 85 | return resultUrlObj.toString(); 86 | }; 87 | 88 | export const isPreviewMode = (): boolean => { 89 | if (getUrlParams()[PREVIEW_MODE_PARAM] === 'true') { 90 | return true; 91 | } 92 | const previewState = getStorageItem( 93 | 'sessionStorage', 94 | PREVIEW_MODE_SESSION_KEY, 95 | ) as PreviewState; 96 | if ( 97 | previewState?.previewFlags && 98 | Object.keys(previewState.previewFlags).length > 0 99 | ) { 100 | return true; 101 | } 102 | return false; 103 | }; 104 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/providers/amplitude.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ExperimentAnalyticsEvent, 3 | ExperimentAnalyticsProvider, 4 | } from '../types/analytics'; 5 | import { ExperimentUserProvider } from '../types/provider'; 6 | import { ExperimentUser } from '../types/user'; 7 | 8 | type AmplitudeIdentify = { 9 | set(property: string, value: unknown): void; 10 | unset(property: string): void; 11 | }; 12 | 13 | type AmplitudeInstance = { 14 | options?: AmplitudeOptions; 15 | _ua?: AmplitudeUAParser; 16 | logEvent(eventName: string, properties: Record): void; 17 | setUserProperties(userProperties: Record): void; 18 | identify(identify: AmplitudeIdentify): void; 19 | }; 20 | 21 | type AmplitudeOptions = { 22 | deviceId?: string; 23 | userId?: string; 24 | versionName?: string; 25 | language?: string; 26 | platform?: string; 27 | }; 28 | 29 | type AmplitudeUAParser = { 30 | browser?: { 31 | name?: string; 32 | major?: string; 33 | }; 34 | os?: { 35 | name?: string; 36 | }; 37 | }; 38 | 39 | /** 40 | * @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless 41 | * integration with the amplitude analytics SDK. 42 | */ 43 | export class AmplitudeUserProvider implements ExperimentUserProvider { 44 | private amplitudeInstance: AmplitudeInstance; 45 | constructor(amplitudeInstance: AmplitudeInstance) { 46 | this.amplitudeInstance = amplitudeInstance; 47 | } 48 | 49 | getUser(): ExperimentUser { 50 | return { 51 | device_id: this.amplitudeInstance?.options?.deviceId, 52 | user_id: this.amplitudeInstance?.options?.userId, 53 | version: this.amplitudeInstance?.options?.versionName, 54 | language: this.amplitudeInstance?.options?.language, 55 | platform: this.amplitudeInstance?.options?.platform, 56 | os: this.getOs(), 57 | device_model: this.getDeviceModel(), 58 | }; 59 | } 60 | 61 | private getOs(): string { 62 | return [ 63 | this.amplitudeInstance?._ua?.browser?.name, 64 | this.amplitudeInstance?._ua?.browser?.major, 65 | ] 66 | .filter((e) => e !== null && e !== undefined) 67 | .join(' '); 68 | } 69 | 70 | private getDeviceModel(): string { 71 | return this.amplitudeInstance?._ua?.os?.name; 72 | } 73 | } 74 | 75 | /** 76 | * @deprecated Update your version of the amplitude analytics-js SDK to 8.17.0+ and for seamless 77 | * integration with the amplitude analytics SDK. 78 | */ 79 | export class AmplitudeAnalyticsProvider implements ExperimentAnalyticsProvider { 80 | private readonly amplitudeInstance: AmplitudeInstance; 81 | constructor(amplitudeInstance: AmplitudeInstance) { 82 | this.amplitudeInstance = amplitudeInstance; 83 | } 84 | 85 | track(event: ExperimentAnalyticsEvent): void { 86 | this.amplitudeInstance.logEvent(event.name, event.properties); 87 | } 88 | 89 | setUserProperty(event: ExperimentAnalyticsEvent): void { 90 | // if the variant has a value, set the user property and log an event 91 | this.amplitudeInstance.setUserProperties({ 92 | [event.userProperty]: event.variant?.value, 93 | }); 94 | } 95 | 96 | unsetUserProperty(event: ExperimentAnalyticsEvent): void { 97 | // if the variant does not have a value, unset the user property 98 | this.amplitudeInstance['_logEvent']('$identify', null, null, { 99 | $unset: { [event.userProperty]: '-' }, 100 | }); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/types/exposure.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Event object for tracking exposures to Amplitude Experiment. 3 | * 4 | * This object contains all the required information to send an `$exposure` 5 | * event through any SDK or CDP to experiment. 6 | * 7 | * The resulting exposure event must follow the following definition: 8 | * ``` 9 | * { 10 | * "event_type": "$exposure", 11 | * "event_properties": { 12 | * "flag_key": "", 13 | * "variant": "", 14 | * "experiment_key": "" 15 | * } 16 | * } 17 | * ``` 18 | * 19 | * Where ``, ``, and `` are the {@link flag_key}, 20 | * {@link variant}, and {@link experiment_key} variant members on this type: 21 | * 22 | * For example, if you're using Segment for analytics: 23 | * 24 | * ``` 25 | * analytics.track('$exposure', exposure) 26 | * ``` 27 | */ 28 | export type Exposure = { 29 | /** 30 | * (Required) The key for the flag the user was exposed to. 31 | */ 32 | flag_key: string; 33 | /** 34 | * (Optional) The variant the user was exposed to. If null or missing, the 35 | * event will not be persisted, and will unset the user property. 36 | */ 37 | variant?: string; 38 | /** 39 | * (Optional) The experiment key used to differentiate between multiple 40 | * experiments associated with the same flag. 41 | */ 42 | experiment_key?: string; 43 | /** 44 | * (Optional) Flag, segment, and variant metadata produced as a result of 45 | * evaluation for the user. Used for system purposes. 46 | */ 47 | metadata?: Record; 48 | /** 49 | * (Optional) The time the exposure occurred. 50 | */ 51 | time?: number; 52 | }; 53 | 54 | /** 55 | * Interface for enabling tracking {@link Exposure}s through the 56 | * {@link ExperimentClient}. 57 | * 58 | * If you're using the Amplitude Analytics SDK for tracking you do not need 59 | * to implement this interface. Simply initialize experiment using the 60 | * {@link Experiment.initializeWithAmplitudeAnalytics} function. 61 | * 62 | * If you're using a 3rd party analytics implementation then you'll need to 63 | * implement the sending of the analytics event yourself. The implementation 64 | * should result in the following event getting sent to amplitude: 65 | * 66 | * ``` 67 | * { 68 | * "event_type": "$exposure", 69 | * "event_properties": { 70 | * "flag_key": "", 71 | * "variant": "", 72 | * "experiment_key": "" 73 | * } 74 | * } 75 | * ``` 76 | * 77 | * For example, if you're using Segment for analytics: 78 | * 79 | * ``` 80 | * analytics.track('$exposure', exposure) 81 | * ``` 82 | */ 83 | export interface ExposureTrackingProvider { 84 | /** 85 | * Called when the {@link ExperimentClient} intends to track an exposure event; 86 | * either when {@link ExperimentClient.variant} serves a variant (and 87 | * {@link ExperimentConfig.automaticExposureTracking} is `true`) or if 88 | * {@link ExperimentClient.exposure} is called. 89 | * 90 | * The implementation should result in the following event getting sent to 91 | * amplitude: 92 | * 93 | * ``` 94 | * { 95 | * "event_type": "$exposure", 96 | * "event_properties": { 97 | * "flag_key": "", 98 | * "variant": "", 99 | * "experiment_key": "" 100 | * } 101 | * } 102 | * ``` 103 | * 104 | * For example, if you're using Segment for analytics: 105 | * 106 | * ``` 107 | * analytics.track('$exposure', exposure) 108 | * ``` 109 | */ 110 | track(exposure: Exposure): void; 111 | } 112 | -------------------------------------------------------------------------------- /packages/experiment-browser/src/factory.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsConnector } from '@amplitude/analytics-connector'; 2 | import { safeGlobal } from '@amplitude/experiment-core'; 3 | 4 | import { Defaults, ExperimentConfig } from './config'; 5 | import { ExperimentClient } from './experimentClient'; 6 | import { AmplitudeIntegrationPlugin } from './integration/amplitude'; 7 | import { DefaultUserProvider } from './providers/default'; 8 | import { ExperimentPlugin } from './types/plugin'; 9 | 10 | // Global instances for debugging. 11 | safeGlobal.experimentInstances = {}; 12 | const instances = safeGlobal.experimentInstances; 13 | 14 | /** 15 | * Initializes a singleton {@link ExperimentClient} identified by the configured 16 | * instance name. 17 | * 18 | * @param apiKey The deployment API Key 19 | * @param config See {@link ExperimentConfig} for config options 20 | */ 21 | export const initialize = ( 22 | apiKey: string, 23 | config?: ExperimentConfig, 24 | ): ExperimentClient => { 25 | return _initialize(apiKey, config); 26 | }; 27 | 28 | /** 29 | * Initialize a singleton {@link ExperimentClient} which automatically 30 | * integrates with the installed and initialized instance of the amplitude 31 | * analytics SDK. 32 | * 33 | * You must be using amplitude-js SDK version 8.17.0+ for this integration to 34 | * work. 35 | * 36 | * @param apiKey The deployment API Key 37 | * @param config See {@link ExperimentConfig} for config options 38 | */ 39 | export const initializeWithAmplitudeAnalytics = ( 40 | apiKey: string, 41 | config?: ExperimentConfig, 42 | ): ExperimentClient => { 43 | const plugin = () => 44 | new AmplitudeIntegrationPlugin( 45 | apiKey, 46 | AnalyticsConnector.getInstance(getInstanceName(config)), 47 | 10000, 48 | ); 49 | return _initialize(apiKey, config, plugin); 50 | }; 51 | 52 | const getInstanceName = (config: ExperimentConfig): string => { 53 | return config?.instanceName || Defaults.instanceName; 54 | }; 55 | 56 | const getInstanceKey = (apiKey: string, config: ExperimentConfig): string => { 57 | // Store instances by appending the instance name and api key. Allows for 58 | // initializing multiple default instances for different api keys. 59 | const instanceName = getInstanceName(config); 60 | // The internal instance name prefix is used by web experiment to differentiate 61 | // web and feature experiment sdks which use the same api key. 62 | const internalInstanceNameSuffix = config?.['internalInstanceNameSuffix']; 63 | return internalInstanceNameSuffix 64 | ? `${instanceName}.${apiKey}.${internalInstanceNameSuffix}` 65 | : `${instanceName}.${apiKey}`; 66 | }; 67 | 68 | const newExperimentClient = ( 69 | apiKey: string, 70 | config: ExperimentConfig, 71 | ): ExperimentClient => { 72 | return new ExperimentClient(apiKey, { 73 | ...config, 74 | userProvider: new DefaultUserProvider(config?.userProvider, apiKey), 75 | }); 76 | }; 77 | 78 | const _initialize = ( 79 | apiKey: string, 80 | config?: ExperimentConfig, 81 | plugin?: () => ExperimentPlugin, 82 | ): ExperimentClient => { 83 | const instanceKey = getInstanceKey(apiKey, config); 84 | let client = instances[instanceKey]; 85 | if (client) { 86 | return client; 87 | } 88 | client = newExperimentClient(apiKey, config); 89 | if (plugin) { 90 | client.addPlugin(plugin()); 91 | } 92 | instances[instanceKey] = client; 93 | return client; 94 | }; 95 | 96 | /** 97 | * Provides factory methods for storing singleton instances of {@link ExperimentClient} 98 | * @category Core Usage 99 | */ 100 | export const Experiment = { 101 | initialize, 102 | initializeWithAmplitudeAnalytics, 103 | }; 104 | -------------------------------------------------------------------------------- /packages/experiment-browser/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { resolve as pathResolve } from 'path'; 2 | 3 | import babel from '@rollup/plugin-babel'; 4 | import commonjs from '@rollup/plugin-commonjs'; 5 | import json from '@rollup/plugin-json'; 6 | import resolve from '@rollup/plugin-node-resolve'; 7 | import replace from '@rollup/plugin-replace'; 8 | import terser from '@rollup/plugin-terser'; 9 | import typescript from '@rollup/plugin-typescript'; 10 | import analyze from 'rollup-plugin-analyzer'; 11 | import gzip from 'rollup-plugin-gzip'; 12 | 13 | import * as packageJson from './package.json'; 14 | import tsConfig from './tsconfig.json'; 15 | 16 | const getCommonBrowserConfig = (target) => ({ 17 | input: 'src/index.ts', 18 | treeshake: { 19 | moduleSideEffects: 'no-external', 20 | }, 21 | plugins: [ 22 | replace({ 23 | preventAssignment: true, 24 | BUILD_BROWSER: true, 25 | }), 26 | resolve(), 27 | json(), 28 | commonjs(), 29 | typescript({ 30 | ...(target === 'es2015' ? { target: 'es2015' } : {}), 31 | declaration: true, 32 | declarationDir: 'dist/types', 33 | include: tsConfig.include, 34 | rootDir: '.', 35 | }), 36 | babel({ 37 | configFile: 38 | target === 'es2015' 39 | ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') 40 | : undefined, 41 | babelHelpers: 'bundled', 42 | exclude: ['node_modules/**'], 43 | }), 44 | analyze({ 45 | summaryOnly: true, 46 | }), 47 | ], 48 | }); 49 | 50 | const getOutputConfig = (outputOptions) => ({ 51 | output: { 52 | dir: 'dist', 53 | name: 'Experiment', 54 | banner: `/* ${packageJson.name} v${packageJson.version} - For license info see https://app.unpkg.com/@amplitude/experiment-js-client@${packageJson.version}/files/LICENSE */`, 55 | ...outputOptions, 56 | }, 57 | }); 58 | 59 | const configs = [ 60 | // legacy build for field "main" - ie8, umd, es5 syntax 61 | { 62 | ...getCommonBrowserConfig('es5'), 63 | ...getOutputConfig({ 64 | entryFileNames: 'experiment.umd.js', 65 | exports: 'named', 66 | format: 'umd', 67 | }), 68 | external: [], 69 | }, 70 | 71 | // tree shakable build for field "module" - ie8, esm, es5 syntax 72 | { 73 | ...getCommonBrowserConfig('es5'), 74 | ...getOutputConfig({ 75 | entryFileNames: 'experiment.esm.js', 76 | format: 'esm', 77 | }), 78 | external: [ 79 | '@amplitude/ua-parser-js', 80 | '@amplitude/analytics-connector', 81 | '@amplitude/experiment-core', 82 | ], 83 | }, 84 | 85 | // modern build for field "es2015" - not ie, esm, es2015 syntax 86 | { 87 | ...getCommonBrowserConfig('es2015'), 88 | ...getOutputConfig({ 89 | entryFileNames: 'experiment.es2015.js', 90 | format: 'esm', 91 | }), 92 | external: [ 93 | '@amplitude/ua-parser-js', 94 | '@amplitude/analytics-connector', 95 | '@amplitude/experiment-core', 96 | ], 97 | }, 98 | { 99 | ...getCommonBrowserConfig('es5'), 100 | ...getOutputConfig({ 101 | entryFileNames: 'experiment-browser.min.js', 102 | exports: 'named', 103 | format: 'umd', 104 | }), 105 | plugins: [ 106 | ...getCommonBrowserConfig('es5').plugins, 107 | terser({ 108 | format: { 109 | comments: 110 | /@amplitude\/.* v(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?/, 111 | }, 112 | }), 113 | gzip(), 114 | ], 115 | external: [], 116 | }, 117 | ]; 118 | 119 | export default configs; 120 | -------------------------------------------------------------------------------- /packages/experiment-tag/test/util/create-flag.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationFlag, EvaluationSegment } from '@amplitude/experiment-core'; 2 | 3 | export const createRedirectFlag = ( 4 | flagKey = 'test', 5 | variant: 'treatment' | 'control' | 'off', 6 | treatmentUrl: string, 7 | controlUrl: string | undefined = undefined, 8 | pageScope: Record = {}, 9 | segments: EvaluationSegment[] = [], 10 | evaluationMode: 'local' | 'remote' = 'local', 11 | ): EvaluationFlag => { 12 | const controlPayload = controlUrl 13 | ? [ 14 | { 15 | action: 'redirect', 16 | data: { 17 | url: controlUrl, 18 | }, 19 | }, 20 | ] 21 | : undefined; 22 | return { 23 | key: flagKey, 24 | metadata: { 25 | deployed: true, 26 | evaluationMode: evaluationMode, 27 | flagType: 'experiment', 28 | deliveryMethod: 'web', 29 | }, 30 | segments: [ 31 | ...segments, 32 | { 33 | metadata: { 34 | segmentName: 'All Other Users', 35 | }, 36 | variant: variant, 37 | }, 38 | ], 39 | variants: { 40 | control: { 41 | key: 'control', 42 | payload: controlPayload, 43 | value: 'control', 44 | }, 45 | off: { 46 | key: 'off', 47 | metadata: { 48 | default: true, 49 | }, 50 | }, 51 | treatment: { 52 | key: 'treatment', 53 | payload: [ 54 | { 55 | action: 'redirect', 56 | data: { 57 | url: treatmentUrl, 58 | metadata: { 59 | scope: pageScope['treatment'], 60 | }, 61 | }, 62 | }, 63 | ], 64 | value: 'treatment', 65 | }, 66 | }, 67 | }; 68 | }; 69 | 70 | export const createFlag = ( 71 | flagKey = 'test', 72 | variant: 'treatment' | 'control' | 'off', 73 | evaluationMode: 'local' | 'remote' = 'local', 74 | blockingEvaluation = true, 75 | metadata: Record = {}, 76 | ): EvaluationFlag => { 77 | return createMutateFlag( 78 | flagKey, 79 | variant, 80 | [], 81 | [], 82 | evaluationMode, 83 | blockingEvaluation, 84 | metadata, 85 | ); 86 | }; 87 | 88 | export const createMutateFlag = ( 89 | flagKey = 'test', 90 | variant: 'treatment' | 'control' | 'off', 91 | treatmentMutations: any[] = [], 92 | segments: any[] = [], 93 | evaluationMode: 'local' | 'remote' = 'local', 94 | blockingEvaluation = true, 95 | metadata: Record = {}, 96 | ): EvaluationFlag => { 97 | return { 98 | key: flagKey, 99 | metadata: { 100 | deployed: true, 101 | evaluationMode: evaluationMode, 102 | flagType: 'experiment', 103 | deliveryMethod: 'web', 104 | blockingEvaluation: evaluationMode === 'remote' && blockingEvaluation, 105 | ...metadata, 106 | }, 107 | segments: [ 108 | ...segments, 109 | { 110 | metadata: { 111 | segmentName: 'All Other Users', 112 | }, 113 | variant: variant, 114 | }, 115 | ], 116 | variants: { 117 | control: { 118 | key: 'control', 119 | payload: undefined, 120 | value: 'control', 121 | }, 122 | off: { 123 | key: 'off', 124 | metadata: { 125 | default: true, 126 | }, 127 | }, 128 | treatment: { 129 | key: 'treatment', 130 | payload: [ 131 | { 132 | action: 'mutate', 133 | data: { 134 | mutations: treatmentMutations, 135 | }, 136 | }, 137 | ], 138 | value: 'treatment', 139 | }, 140 | }, 141 | }; 142 | }; 143 | -------------------------------------------------------------------------------- /examples/html-app/amplitude-integration/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Experiment Example - Amplitude Integration 8 | 9 | 13 | 27 | 28 | 31 | 32 | 40 | 41 | 42 |

Amplitude Experiment Browser Example - Amplitude Integration

43 | 44 |

Click "Fetch" to fetch variants, then "Variant" or "All" to access variants from the SDK.

45 |

Open the console to view debug output from the SDK.

46 | 47 | 50 | 51 | 52 | 56 | 57 | 61 | 62 |
63 | 64 |

65 |   
66 | 
67 | 


--------------------------------------------------------------------------------
/packages/experiment-browser/src/types/analytics.ts:
--------------------------------------------------------------------------------
  1 | import { VariantSource } from './source';
  2 | import { ExperimentUser } from './user';
  3 | import { Variant } from './variant';
  4 | 
  5 | /**
  6 |  * Analytics event for tracking events generated from the experiment SDK client.
  7 |  * These events are sent to the implementation provided by an
  8 |  * {@link ExperimentAnalyticsProvider}.
  9 |  * @category Analytics
 10 |  *
 11 |  * @deprecated use ExposureTrackingProvider instead
 12 |  */
 13 | export interface ExperimentAnalyticsEvent {
 14 |   /**
 15 |    * The name of the event. Should be passed as the event tracking name to the
 16 |    * analytics implementation provided by the
 17 |    * {@link ExperimentAnalyticsProvider}.
 18 |    */
 19 |   name: string;
 20 | 
 21 |   /**
 22 |    * Event properties for the analytics event. Should be passed as the event
 23 |    * properties to the analytics implementation provided by the
 24 |    * {@link ExperimentAnalyticsProvider}.
 25 |    * This is equivalent to
 26 |    * ```
 27 |    * {
 28 |    *   "key": key,
 29 |    *   "variant": variant,
 30 |    * }
 31 |    * ```
 32 |    */
 33 |   properties: Record;
 34 | 
 35 |   /**
 36 |    * User properties to identify with the user prior to sending the event.
 37 |    * This is equivalent to
 38 |    * ```
 39 |    * {
 40 |    *   [userProperty]: variant
 41 |    * }
 42 |    * ```
 43 |    */
 44 |   userProperties?: Record;
 45 | 
 46 |   /**
 47 |    * The user exposed to the flag/experiment variant.
 48 |    */
 49 |   user: ExperimentUser;
 50 | 
 51 |   /**
 52 |    * The key of the flag/experiment that the user has been exposed to.
 53 |    */
 54 |   key: string;
 55 | 
 56 |   /**
 57 |    * The variant of the flag/experiment that the user has been exposed to.
 58 |    */
 59 |   variant: Variant;
 60 | 
 61 |   /**
 62 |    * The user property for the flag/experiment (auto-generated from the key)
 63 |    */
 64 |   userProperty: string;
 65 | }
 66 | 
 67 | /**
 68 |  * Provides a analytics implementation for standard experiment events generated
 69 |  * by the client (e.g. {@link ExposureEvent}).
 70 |  * @category Provider
 71 |  *
 72 |  * @deprecated use ExposureTrackingProvider instead
 73 |  */
 74 | export interface ExperimentAnalyticsProvider {
 75 |   /**
 76 |    * Wraps an analytics event track call. This is typically called by the
 77 |    * experiment client after setting user properties to track an
 78 |    * "[Experiment] Exposure" event
 79 |    * @param event see {@link ExperimentAnalyticsEvent}
 80 |    */
 81 |   track(event: ExperimentAnalyticsEvent): void;
 82 | 
 83 |   /**
 84 |    * Wraps an analytics identify or set user property call. This is typically
 85 |    * called by the experiment client before sending an
 86 |    * "[Experiment] Exposure" event.
 87 |    * @param event see {@link ExperimentAnalyticsEvent}
 88 |    */
 89 |   setUserProperty?(event: ExperimentAnalyticsEvent): void;
 90 | 
 91 |   /**
 92 |    * Wraps an analytics unset user property call. This is typically
 93 |    * called by the experiment client when a user has been evaluated to use
 94 |    * a fallback variant.
 95 |    * @param event see {@link ExperimentAnalyticsEvent}
 96 |    */
 97 |   unsetUserProperty?(event: ExperimentAnalyticsEvent): void;
 98 | }
 99 | 
100 | /**
101 |  * Event for tracking a user's exposure to a variant. This event will not count
102 |  * towards your analytics event volume.
103 |  *
104 |  * @deprecated use ExposureTrackingProvider instead
105 |  */
106 | export const exposureEvent = (
107 |   user: ExperimentUser,
108 |   key: string,
109 |   variant: Variant,
110 |   source: VariantSource,
111 | ): ExperimentAnalyticsEvent => {
112 |   const name = '[Experiment] Exposure';
113 |   const value = variant?.value;
114 |   const userProperty = `[Experiment] ${key}`;
115 |   return {
116 |     name,
117 |     user,
118 |     key,
119 |     variant,
120 |     userProperty,
121 |     properties: {
122 |       key,
123 |       variant: value,
124 |       source,
125 |     },
126 |     userProperties: {
127 |       [userProperty]: value,
128 |     },
129 |   };
130 | };
131 | 


--------------------------------------------------------------------------------
/.github/workflows/publish-to-s3.yml:
--------------------------------------------------------------------------------
  1 | name: Publish Experiment Packages Branch to S3
  2 | 
  3 | on:
  4 |   workflow_dispatch:
  5 |     inputs:
  6 |       includeTag:
  7 |         description: 'Include experiment-tag package'
  8 |         type: boolean
  9 |         required: true
 10 |         default: true
 11 |       includeSegmentPlugin:
 12 |         description: 'Include experiment-plugin-segment package'
 13 |         type: boolean
 14 |         required: true
 15 |         default: true
 16 |       includeChromeExtension:
 17 |         description: 'Release experiment-tag package for chrome extension'
 18 |         type: boolean
 19 |         required: true
 20 |         default: true
 21 | 
 22 | jobs:
 23 |   authorize:
 24 |     name: Authorize
 25 |     runs-on: ubuntu-latest
 26 |     steps:
 27 |       - name: ${{ github.actor }} permission check
 28 |         uses: 'lannonbr/repo-permission-check-action@2.0.2'
 29 |         with:
 30 |           permission: 'write'
 31 |         env:
 32 |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
 33 | 
 34 |   build-and-deploy:
 35 |     runs-on: ubuntu-latest
 36 |     needs: [authorize]
 37 |     permissions:
 38 |       id-token: write
 39 |       contents: read
 40 |     steps:
 41 |       - name: Checkout
 42 |         uses: actions/checkout@v3
 43 |         with:
 44 |           ref: ${{ github.ref_name }}
 45 | 
 46 |       - name: Setup Node
 47 |         uses: actions/setup-node@v3
 48 |         with:
 49 |           node-version: '20'
 50 |           cache: 'yarn'
 51 | 
 52 |       - name: Set up SSH for deploy key
 53 |         run: |
 54 |           mkdir -p ~/.ssh
 55 |           echo "${{ secrets.DOM_MUTATOR_ACCESS_KEY }}" > ~/.ssh/id_ed25519
 56 |           chmod 600 ~/.ssh/id_ed25519
 57 |           ssh-keyscan github.com >> ~/.ssh/known_hosts
 58 | 
 59 |       - name: Install root dependencies
 60 |         run: yarn install --frozen-lockfile
 61 | 
 62 |       - name: Install AWS SDK dependencies
 63 |         run: cd scripts && yarn install
 64 | 
 65 |       - name: Build all packages
 66 |         run: cd packages && yarn build
 67 | 
 68 |       - name: Get branch name
 69 |         id: branch-name
 70 |         run: |
 71 |           BRANCH_NAME="${{ github.ref_name }}"
 72 |           if [[ "$BRANCH_NAME" == "main" ]]; then
 73 |             echo "branch_name_safe=" >> $GITHUB_OUTPUT
 74 |           else
 75 |             # Strip "web/" prefix if present
 76 |             BRANCH_NAME_WITHOUT_PREFIX=${BRANCH_NAME#web/}
 77 |             # Replace remaining slashes with hyphens
 78 |             BRANCH_NAME_SAFE=$(echo "$BRANCH_NAME_WITHOUT_PREFIX" | sed 's/\//-/g')
 79 |             echo "branch_name_safe=$BRANCH_NAME_SAFE" >> $GITHUB_OUTPUT
 80 |           fi
 81 | 
 82 |       - name: Determine packages to upload
 83 |         id: packages-to-upload
 84 |         run: |
 85 |           PACKAGES=""
 86 |           if [[ "${{ github.event.inputs.includeTag }}" == "true" ]]; then
 87 |             PACKAGES="tag"
 88 |           fi
 89 |           
 90 |           if [[ "${{ github.event.inputs.includeSegmentPlugin }}" == "true" ]]; then
 91 |             if [[ -n "$PACKAGES" ]]; then
 92 |               PACKAGES="$PACKAGES,segment-plugin"
 93 |             else
 94 |               PACKAGES="segment-plugin"
 95 |             fi
 96 |           fi
 97 | 
 98 |           if [[ "${{ github.event.inputs.includeChromeExtension }}" == "true" ]]; then
 99 |             if [[ -n "$PACKAGES" ]]; then
100 |               PACKAGES="$PACKAGES,chrome-extension"
101 |             else
102 |               PACKAGES="chrome-extension"
103 |             fi
104 |           fi
105 |           
106 |           if [[ -z "$PACKAGES" ]]; then
107 |             echo "No packages selected for upload"
108 |             exit 1
109 |           fi
110 |           
111 |           echo "packages=$PACKAGES" >> $GITHUB_OUTPUT
112 | 
113 |       - name: Configure AWS Credentials
114 |         uses: aws-actions/configure-aws-credentials@v2
115 |         with:
116 |           role-to-assume: ${{ secrets.AWS_ROLE_ARN }}
117 |           aws-region: us-west-2
118 | 
119 |       - name: Upload to S3 with branch name
120 |         env:
121 |           S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }}
122 |           BRANCH_NAME: ${{ steps.branch-name.outputs.branch_name_safe }}
123 |           PACKAGES: ${{ steps.packages-to-upload.outputs.packages }}
124 |         run: node scripts/upload-to-s3.js
125 | 


--------------------------------------------------------------------------------