├── .eslintignore ├── .eslintrc.js ├── .github ├── pull_request_template.md └── workflows │ ├── jira-issue-create.yml │ ├── lint.yml │ ├── release.yml │ ├── semantic-pr.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── babel.config.js ├── babel.es2015.config.js ├── docs ├── .nojekyll ├── assets │ ├── highlight.css │ ├── main.js │ ├── search.js │ └── style.css ├── classes │ ├── AmplitudeAnalyticsProvider.html │ ├── AmplitudeIntegrationPlugin.html │ ├── AmplitudeUserProvider.html │ ├── ExperimentClient.html │ └── StubExperimentClient.html ├── enums │ └── Source.html ├── functions │ ├── initialize.html │ └── initializeWithAmplitudeAnalytics.html ├── index.html ├── interfaces │ ├── Client.html │ ├── ExperimentAnalyticsEvent.html │ ├── ExperimentAnalyticsProvider.html │ ├── ExperimentConfig.html │ ├── ExperimentPlugin.html │ ├── ExperimentUserProvider.html │ ├── ExposureTrackingProvider.html │ └── IntegrationPlugin.html ├── types │ ├── ExperimentEvent.html │ ├── ExperimentPluginType.html │ ├── ExperimentUser.html │ ├── Exposure.html │ ├── FetchOptions.html │ ├── Variant.html │ └── Variants.html └── variables │ └── Experiment.html ├── examples ├── html-app │ ├── amplitude-integration │ │ ├── README.md │ │ ├── index.html │ │ └── package.json │ └── basic │ │ ├── README.md │ │ ├── index.html │ │ └── package.json └── react-app │ ├── amplitude-integration │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── App.css │ │ ├── App.tsx │ │ ├── index.css │ │ ├── index.tsx │ │ ├── logo.svg │ │ └── react-app-env.d.ts │ ├── tsconfig.json │ └── yarn.lock │ └── basic │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt │ ├── src │ ├── App.css │ ├── App.tsx │ ├── index.css │ ├── index.tsx │ ├── logo.svg │ └── react-app-env.d.ts │ ├── tsconfig.json │ └── yarn.lock ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── analytics-connector │ ├── CHANGELOG.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── analyticsConnector.ts │ │ ├── applicationContextProvider.ts │ │ ├── eventBridge.ts │ │ ├── identityStore.ts │ │ ├── index.ts │ │ └── util │ │ │ ├── equals.ts │ │ │ └── global.ts │ ├── test │ │ ├── analyticsConnector.test.ts │ │ ├── equals.test.ts │ │ ├── eventBridge.test.ts │ │ └── identityStore.test.ts │ ├── tsconfig.json │ └── tsconfig.test.json ├── experiment-browser │ ├── CHANGELOG.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── config.ts │ │ ├── experimentClient.ts │ │ ├── factory.ts │ │ ├── index.ts │ │ ├── integration │ │ │ ├── amplitude.ts │ │ │ └── manager.ts │ │ ├── providers │ │ │ ├── amplitude.ts │ │ │ └── default.ts │ │ ├── storage │ │ │ ├── cache.ts │ │ │ ├── local-storage.ts │ │ │ └── session-storage.ts │ │ ├── stubClient.ts │ │ ├── transport │ │ │ └── http.ts │ │ ├── types │ │ │ ├── analytics.ts │ │ │ ├── client.ts │ │ │ ├── exposure.ts │ │ │ ├── plugin.ts │ │ │ ├── provider.ts │ │ │ ├── source.ts │ │ │ ├── storage.ts │ │ │ ├── transport.ts │ │ │ ├── user.ts │ │ │ └── variant.ts │ │ └── util │ │ │ ├── backoff.ts │ │ │ ├── base64.ts │ │ │ ├── convert.ts │ │ │ ├── index.ts │ │ │ ├── randomstring.ts │ │ │ ├── sessionAnalyticsProvider.ts │ │ │ ├── sessionExposureTrackingProvider.ts │ │ │ └── state.ts │ ├── test │ │ ├── base64.test.ts │ │ ├── client.test.ts │ │ ├── convert.test.ts │ │ ├── defaultUserProvider.test.ts │ │ ├── factory.test.ts │ │ ├── integration │ │ │ ├── amplitude.test.ts │ │ │ └── manager.test.ts │ │ ├── storage.test.ts │ │ └── util │ │ │ ├── misc.ts │ │ │ ├── mock.ts │ │ │ └── state.test.ts │ ├── tsconfig.json │ └── tsconfig.test.json ├── experiment-core │ ├── CHANGELOG.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── api │ │ │ ├── evaluation-api.ts │ │ │ └── flag-api.ts │ │ ├── evaluation │ │ │ ├── error.ts │ │ │ ├── evaluation.ts │ │ │ ├── flag.ts │ │ │ ├── murmur3.ts │ │ │ ├── select.ts │ │ │ ├── semantic-version.ts │ │ │ ├── topological-sort.ts │ │ │ └── utils.ts │ │ ├── index.ts │ │ ├── transport │ │ │ └── http.ts │ │ └── util │ │ │ ├── global.ts │ │ │ └── poller.ts │ ├── test │ │ └── evaluation │ │ │ ├── evaluation-integration.test.ts │ │ │ ├── murmur3.test.ts │ │ │ ├── selector.test.ts │ │ │ ├── semantic-version.test.ts │ │ │ └── topological-sort.test.ts │ └── tsconfig.json ├── experiment-tag │ ├── CHANGELOG.md │ ├── README.md │ ├── example │ │ └── build_example.js │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── config.ts │ │ ├── experiment.ts │ │ ├── index.ts │ │ ├── inject-utils.ts │ │ ├── message-bus.ts │ │ ├── messenger.ts │ │ ├── mutation-manager.ts │ │ ├── subscriptions.ts │ │ ├── types.ts │ │ ├── util.ts │ │ └── web-experiment.ts │ ├── test │ │ ├── experiment.test.ts │ │ ├── util.test.ts │ │ └── util │ │ │ ├── create-flag.ts │ │ │ ├── create-page-object.ts │ │ │ └── mock-http-client.ts │ ├── tsconfig.json │ └── tsconfig.test.json └── plugin-segment │ ├── CHANGELOG.md │ ├── jest.config.js │ ├── package.json │ ├── rollup.config.js │ ├── src │ ├── global.ts │ ├── index.ts │ ├── plugin.ts │ ├── snippet.ts │ └── types │ │ └── plugin.ts │ ├── test │ └── plugin.test.ts │ ├── tsconfig.json │ └── tsconfig.test.json ├── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | example/ 3 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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@v2 24 | with: 25 | node-version: 16 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 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | dryRun: 7 | description: 'Do a dry run to preview instead of a real release' 8 | required: true 9 | default: 'true' 10 | 11 | jobs: 12 | release: 13 | name: Release 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v2 18 | 19 | # Needed for lerna version to determine last tag 20 | - name: Fetch 21 | run: git fetch --prune --unshallow --tags 22 | 23 | - name: Cache Node Modules 24 | uses: actions/cache@v4 25 | with: 26 | path: '**/node_modules' 27 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 28 | 29 | - name: Setup Node 30 | uses: actions/setup-node@v2 31 | with: 32 | node-version: '16' 33 | 34 | - name: Set up SSH for deploy key 35 | run: | 36 | mkdir -p ~/.ssh 37 | echo "${{ secrets.DOM_MUTATOR_ACCESS_KEY }}" > ~/.ssh/id_ed25519 38 | chmod 600 ~/.ssh/id_ed25519 39 | ssh-keyscan github.com >> ~/.ssh/known_hosts 40 | continue-on-error: true # forked repos don't have access, and this is only for experiment-tag 41 | shell: bash 42 | 43 | - name: Install 44 | run: yarn install --frozen-lockfile 45 | 46 | - name: Build 47 | run: npx lerna exec yarn 48 | 49 | - name: Test 50 | run: yarn test 51 | 52 | - name: Configure Git User 53 | run: | 54 | git config --global user.name amplitude-sdk-bot 55 | git config --global user.email amplitude-sdk-bot@users.noreply.github.com 56 | 57 | - name: Release (Dry Run) 58 | if: ${{ github.event.inputs.dryRun == 'true'}} 59 | env: 60 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | run: yarn lerna version --no-push --no-git-tag-version --loglevel silly --yes 62 | 63 | - name: Setup NPM Token 64 | if: ${{ github.event.inputs.dryRun == 'false'}} 65 | env: 66 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 67 | run: | 68 | echo //registry.npmjs.org/:_authToken=${NPM_TOKEN} > .npmrc 69 | 70 | - name: Release 71 | if: ${{ github.event.inputs.dryRun == 'false'}} 72 | env: 73 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | run: | 75 | yarn lerna version --yes 76 | yarn lerna publish from-git --yes --loglevel silly 77 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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: ['16', '18'] 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: Install 43 | run: yarn install --frozen-lockfile --force 44 | 45 | - name: Build 46 | run: npx lerna exec yarn --stream 47 | 48 | - name: Test 49 | run: yarn test 50 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | example/ 3 | *.md 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all", 4 | "proseWrap": "always" 5 | } 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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. -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | 


--------------------------------------------------------------------------------
/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/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 | 


--------------------------------------------------------------------------------
/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/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/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 | 


--------------------------------------------------------------------------------
/examples/react-app/amplitude-integration/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/amplitude/experiment-js-client/398590f08ed223c450cdf7dd90ffdc9d7a1ef662/examples/react-app/amplitude-integration/public/favicon.ico


--------------------------------------------------------------------------------
/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 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/398590f08ed223c450cdf7dd90ffdc9d7a1ef662/examples/react-app/amplitude-integration/public/logo192.png -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/398590f08ed223c450cdf7dd90ffdc9d7a1ef662/examples/react-app/amplitude-integration/public/logo512.png -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react-app/amplitude-integration/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/398590f08ed223c450cdf7dd90ffdc9d7a1ef662/examples/react-app/basic/public/favicon.ico -------------------------------------------------------------------------------- /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/react-app/basic/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/398590f08ed223c450cdf7dd90ffdc9d7a1ef662/examples/react-app/basic/public/logo192.png -------------------------------------------------------------------------------- /examples/react-app/basic/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/experiment-js-client/398590f08ed223c450cdf7dd90ffdc9d7a1ef662/examples/react-app/basic/public/logo512.png -------------------------------------------------------------------------------- /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/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/react-app/basic/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | projects: ['/packages/*/jest.config.js'], 3 | }; 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": [ 3 | "packages/*" 4 | ], 5 | "version": "independent", 6 | "npmClient": "yarn", 7 | "useWorkspaces": true, 8 | "command": { 9 | "version": { 10 | "allowBranch": "main", 11 | "conventionalCommits": true, 12 | "createRelease": "github", 13 | "message": "chore(release): publish", 14 | "preid": "beta" 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /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": "^14.6.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": "^6.6.1", 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.24.1", 43 | "typescript": "^5.0.4" 44 | }, 45 | "resolutions": { 46 | "@testing-library/dom": "7.26.0" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /packages/analytics-connector/src/identityStore.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from './util/equals'; 2 | 3 | const ID_OP_SET = '$set'; 4 | const ID_OP_UNSET = '$unset'; 5 | const ID_OP_CLEAR_ALL = '$clearAll'; 6 | 7 | // Polyfill for Object.entries 8 | if (!Object.entries) { 9 | Object.entries = function (obj) { 10 | const ownProps = Object.keys(obj); 11 | let i = ownProps.length; 12 | const resArray = new Array(i); 13 | while (i--) { 14 | resArray[i] = [ownProps[i], obj[ownProps[i]]]; 15 | } 16 | return resArray; 17 | }; 18 | } 19 | 20 | export type Identity = { 21 | userId?: string; 22 | deviceId?: string; 23 | userProperties?: Record; 24 | optOut?: boolean; 25 | }; 26 | 27 | export type IdentityListener = (identity: Identity) => void; 28 | 29 | export interface IdentityStore { 30 | editIdentity(): IdentityEditor; 31 | getIdentity(): Identity; 32 | setIdentity(identity: Identity): void; 33 | addIdentityListener(listener: IdentityListener): void; 34 | removeIdentityListener(listener: IdentityListener): void; 35 | } 36 | 37 | export interface IdentityEditor { 38 | setUserId(userId: string): IdentityEditor; 39 | setDeviceId(deviceId: string): IdentityEditor; 40 | setUserProperties(userProperties: Record): IdentityEditor; 41 | setOptOut(optOut: boolean): IdentityEditor; 42 | updateUserProperties( 43 | actions: Record>, 44 | ): IdentityEditor; 45 | commit(): void; 46 | } 47 | 48 | export class IdentityStoreImpl implements IdentityStore { 49 | private identity: Identity = { userProperties: {} }; 50 | private listeners = new Set(); 51 | 52 | editIdentity(): IdentityEditor { 53 | // eslint-disable-next-line @typescript-eslint/no-this-alias 54 | const self: IdentityStore = this; 55 | const actingUserProperties = { ...this.identity.userProperties }; 56 | const actingIdentity: Identity = { 57 | ...this.identity, 58 | userProperties: actingUserProperties, 59 | }; 60 | return { 61 | setUserId: function (userId: string): IdentityEditor { 62 | actingIdentity.userId = userId; 63 | return this; 64 | }, 65 | 66 | setDeviceId: function (deviceId: string): IdentityEditor { 67 | actingIdentity.deviceId = deviceId; 68 | return this; 69 | }, 70 | 71 | setUserProperties: function ( 72 | userProperties: Record, 73 | ): IdentityEditor { 74 | actingIdentity.userProperties = userProperties; 75 | return this; 76 | }, 77 | 78 | setOptOut(optOut: boolean): IdentityEditor { 79 | actingIdentity.optOut = optOut; 80 | return this; 81 | }, 82 | 83 | updateUserProperties: function ( 84 | actions: Record>, 85 | ): IdentityEditor { 86 | let actingProperties = actingIdentity.userProperties || {}; 87 | for (const [action, properties] of Object.entries(actions)) { 88 | switch (action) { 89 | case ID_OP_SET: 90 | for (const [key, value] of Object.entries(properties)) { 91 | actingProperties[key] = value; 92 | } 93 | break; 94 | case ID_OP_UNSET: 95 | for (const key of Object.keys(properties)) { 96 | delete actingProperties[key]; 97 | } 98 | break; 99 | case ID_OP_CLEAR_ALL: 100 | actingProperties = {}; 101 | break; 102 | } 103 | } 104 | actingIdentity.userProperties = actingProperties; 105 | return this; 106 | }, 107 | 108 | commit: function (): void { 109 | self.setIdentity(actingIdentity); 110 | return this; 111 | }, 112 | }; 113 | } 114 | 115 | getIdentity(): Identity { 116 | return { ...this.identity }; 117 | } 118 | 119 | setIdentity(identity: Identity): void { 120 | const originalIdentity = { ...this.identity }; 121 | this.identity = { ...identity }; 122 | if (!isEqual(originalIdentity, this.identity)) { 123 | this.listeners.forEach((listener) => { 124 | listener(identity); 125 | }); 126 | } 127 | } 128 | 129 | addIdentityListener(listener: IdentityListener): void { 130 | this.listeners.add(listener); 131 | } 132 | 133 | removeIdentityListener(listener: IdentityListener): void { 134 | this.listeners.delete(listener); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/analytics-connector/test/identityStore.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | import amplitude from 'amplitude-js'; 3 | 4 | import { IdentityStoreImpl } from '../src/identityStore'; 5 | 6 | test('editIdentity, setUserId setDeviceId, success', async () => { 7 | const identityStore = new IdentityStoreImpl(); 8 | identityStore 9 | .editIdentity() 10 | .setUserId('user_id') 11 | .setDeviceId('device_id') 12 | .setOptOut(true) 13 | .commit(); 14 | const identity = identityStore.getIdentity(); 15 | expect(identity).toEqual({ 16 | userId: 'user_id', 17 | deviceId: 'device_id', 18 | userProperties: {}, 19 | optOut: true, 20 | }); 21 | }); 22 | 23 | test('editIdentity, setUserId setDeviceId, identity listener called', async () => { 24 | const identityStore = new IdentityStoreImpl(); 25 | const expectedIdentity = { 26 | userId: 'user_id', 27 | deviceId: 'device_id', 28 | userProperties: {}, 29 | }; 30 | let listenerCalled = false; 31 | identityStore.addIdentityListener((identity) => { 32 | expect(identity).toEqual(expectedIdentity); 33 | listenerCalled = true; 34 | }); 35 | identityStore 36 | .editIdentity() 37 | .setUserId('user_id') 38 | .setDeviceId('device_id') 39 | .commit(); 40 | expect(listenerCalled).toEqual(true); 41 | }); 42 | 43 | test('editIdentity, updateUserProperties, identity listener called', async () => { 44 | const identityStore = new IdentityStoreImpl(); 45 | let listenerCalled = false; 46 | identityStore.addIdentityListener(() => { 47 | listenerCalled = true; 48 | }); 49 | 50 | identityStore 51 | .editIdentity() 52 | .setUserId('user_id') 53 | .setDeviceId('device_id') 54 | .commit(); 55 | expect(listenerCalled).toEqual(true); 56 | 57 | listenerCalled = false; 58 | identityStore 59 | .editIdentity() 60 | .updateUserProperties({ $set: { test: 'test' } }) 61 | .commit(); 62 | expect(listenerCalled).toEqual(true); 63 | 64 | listenerCalled = false; 65 | identityStore 66 | .editIdentity() 67 | .updateUserProperties({ $set: { test: 'test2' } }) 68 | .commit(); 69 | expect(listenerCalled).toEqual(true); 70 | }); 71 | 72 | test('setIdentity, getIdentity, success', async () => { 73 | const identityStore = new IdentityStoreImpl(); 74 | const expectedIdentity = { userId: 'user_id', deviceId: 'device_id' }; 75 | identityStore.setIdentity(expectedIdentity); 76 | const identity = identityStore.getIdentity(); 77 | expect(identity).toEqual(expectedIdentity); 78 | }); 79 | 80 | test('setIdentity, identity listener called', async () => { 81 | const identityStore = new IdentityStoreImpl(); 82 | const expectedIdentity = { userId: 'user_id', deviceId: 'device_id' }; 83 | let listenerCalled = false; 84 | identityStore.addIdentityListener((identity) => { 85 | expect(identity).toEqual(expectedIdentity); 86 | listenerCalled = true; 87 | }); 88 | identityStore.setIdentity(expectedIdentity); 89 | expect(listenerCalled).toEqual(true); 90 | }); 91 | 92 | test('setIdentity with unchanged identity, identity listener not called', async () => { 93 | const identityStore = new IdentityStoreImpl(); 94 | const expectedIdentity = { userId: 'user_id', deviceId: 'device_id' }; 95 | identityStore.setIdentity(expectedIdentity); 96 | identityStore.addIdentityListener(() => { 97 | fail('identity listener should not be called'); 98 | }); 99 | identityStore.setIdentity(expectedIdentity); 100 | }); 101 | 102 | test('updateUserProperties, set', async () => { 103 | const identityStore = new IdentityStoreImpl(); 104 | const identify = new amplitude.Identify() 105 | .set('string', 'string') 106 | .set('int', 32) 107 | .set('bool', true) 108 | .set('double', 4.2) 109 | .set('jsonArray', [0, 1.1, true, 'three']) 110 | .set('jsonObject', { key: 'value' }); 111 | identityStore 112 | .editIdentity() 113 | .updateUserProperties(identify['userPropertiesOperations']) 114 | .commit(); 115 | const identity = identityStore.getIdentity(); 116 | expect(identity).toEqual({ 117 | userProperties: { 118 | string: 'string', 119 | int: 32, 120 | bool: true, 121 | double: 4.2, 122 | jsonArray: [0, 1.1, true, 'three'], 123 | jsonObject: { key: 'value' }, 124 | }, 125 | }); 126 | }); 127 | 128 | test('updateUserProperties, unset', async () => { 129 | const identityStore = new IdentityStoreImpl(); 130 | identityStore.setIdentity({ userProperties: { key: 'value' } }); 131 | const identify = new amplitude.Identify().unset('key'); 132 | identityStore 133 | .editIdentity() 134 | .updateUserProperties(identify['userPropertiesOperations']) 135 | .commit(); 136 | const identity = identityStore.getIdentity(); 137 | expect(identity).toEqual({ userProperties: {} }); 138 | }); 139 | -------------------------------------------------------------------------------- /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/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/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 | -------------------------------------------------------------------------------- /packages/experiment-browser/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-js-client", 3 | "version": "1.15.6", 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.11.0", 39 | "@amplitude/ua-parser-js": "^0.7.31", 40 | "base64-js": "1.5.1", 41 | "unfetch": "4.1.0" 42 | }, 43 | "devDependencies": { 44 | "@types/amplitude-js": "^8.0.2", 45 | "amplitude-js": "^8.12.0" 46 | }, 47 | "files": [ 48 | "dist" 49 | ] 50 | } 51 | -------------------------------------------------------------------------------- /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 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: 'experiment.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: 'experiment.esm.js', 72 | format: 'esm', 73 | }), 74 | external: [ 75 | '@amplitude/ua-parser-js', 76 | '@amplitude/analytics-connector', 77 | '@amplitude/experiment-core', 78 | ], 79 | }, 80 | 81 | // modern build for field "es2015" - not ie, esm, es2015 syntax 82 | { 83 | ...getCommonBrowserConfig('es2015'), 84 | ...getOutputConfig({ 85 | entryFileNames: 'experiment.es2015.js', 86 | format: 'esm', 87 | }), 88 | external: [ 89 | '@amplitude/ua-parser-js', 90 | '@amplitude/analytics-connector', 91 | '@amplitude/experiment-core', 92 | ], 93 | }, 94 | ]; 95 | 96 | export default configs; 97 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/storage/cache.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationFlag } from '@amplitude/experiment-core'; 2 | 3 | import { Storage } from '../types/storage'; 4 | import { Variant } from '../types/variant'; 5 | 6 | import { LocalStorage } from './local-storage'; 7 | 8 | export const getVariantStorage = ( 9 | deploymentKey: string, 10 | instanceName: string, 11 | storage: Storage, 12 | ): LoadStoreCache => { 13 | const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6); 14 | const namespace = `amp-exp-${instanceName}-${truncatedDeployment}`; 15 | return new LoadStoreCache( 16 | namespace, 17 | storage, 18 | transformVariantFromStorage, 19 | ); 20 | }; 21 | 22 | export const getFlagStorage = ( 23 | deploymentKey: string, 24 | instanceName: string, 25 | storage: Storage = new LocalStorage(), 26 | ): LoadStoreCache => { 27 | const truncatedDeployment = deploymentKey.substring(deploymentKey.length - 6); 28 | const namespace = `amp-exp-${instanceName}-${truncatedDeployment}-flags`; 29 | return new LoadStoreCache(namespace, storage); 30 | }; 31 | 32 | export class LoadStoreCache { 33 | private readonly namespace: string; 34 | private readonly storage: Storage; 35 | private readonly transformer: (value: unknown) => V | undefined; 36 | private cache: Record = {}; 37 | 38 | constructor( 39 | namespace: string, 40 | storage: Storage, 41 | transformer?: (value: unknown) => V, 42 | ) { 43 | this.namespace = namespace; 44 | this.storage = storage; 45 | this.transformer = transformer; 46 | } 47 | 48 | public get(key: string): V | undefined { 49 | return this.cache[key]; 50 | } 51 | 52 | public getAll(): Record { 53 | return { ...this.cache }; 54 | } 55 | 56 | public put(key: string, value: V): void { 57 | this.cache[key] = value; 58 | } 59 | 60 | public putAll(values: Record): void { 61 | for (const key of Object.keys(values)) { 62 | this.cache[key] = values[key]; 63 | } 64 | } 65 | 66 | public remove(key: string): void { 67 | delete this.cache[key]; 68 | } 69 | 70 | public clear(): void { 71 | this.cache = {}; 72 | } 73 | 74 | public load() { 75 | const rawValues = this.storage.get(this.namespace); 76 | let jsonValues: Record; 77 | try { 78 | jsonValues = JSON.parse(rawValues) || {}; 79 | } catch { 80 | // Do nothing 81 | return; 82 | } 83 | const values: Record = {}; 84 | for (const key of Object.keys(jsonValues)) { 85 | try { 86 | let value: V; 87 | if (this.transformer) { 88 | value = this.transformer(jsonValues[key]); 89 | } else { 90 | value = jsonValues[key] as V; 91 | } 92 | if (value) { 93 | values[key] = value; 94 | } 95 | } catch { 96 | // Do nothing 97 | } 98 | } 99 | this.clear(); 100 | this.putAll(values); 101 | } 102 | 103 | public store(values: Record = this.cache) { 104 | this.storage.put(this.namespace, JSON.stringify(values)); 105 | } 106 | } 107 | 108 | export const transformVariantFromStorage = (storageValue: unknown): Variant => { 109 | if (typeof storageValue === 'string') { 110 | // From v0 string format 111 | return { 112 | key: storageValue, 113 | value: storageValue, 114 | }; 115 | } else if (typeof storageValue === 'object') { 116 | // From v1 or v2 object format 117 | const key = storageValue['key']; 118 | const value = storageValue['value']; 119 | const payload = storageValue['payload']; 120 | let metadata = storageValue['metadata']; 121 | let experimentKey = storageValue['expKey']; 122 | if (metadata && metadata.experimentKey) { 123 | experimentKey = metadata.experimentKey; 124 | } else if (experimentKey) { 125 | metadata = metadata || {}; 126 | metadata['experimentKey'] = experimentKey; 127 | } 128 | const variant: Variant = {}; 129 | if (key) { 130 | variant.key = key; 131 | } else if (value) { 132 | variant.key = value; 133 | } 134 | if (value) variant.value = value; 135 | if (metadata) variant.metadata = metadata; 136 | if (payload) variant.payload = payload; 137 | if (experimentKey) variant.expKey = experimentKey; 138 | return variant; 139 | } 140 | }; 141 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 } 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(user: ExperimentUser): Promise { 26 | return this; 27 | } 28 | 29 | public getUserProvider(): ExperimentUserProvider { 30 | return null; 31 | } 32 | 33 | public setUserProvider( 34 | uerProvider: ExperimentUserProvider, 35 | ): StubExperimentClient { 36 | return this; 37 | } 38 | 39 | public variant(key: string, fallback?: string | Variant): Variant { 40 | return Defaults.fallbackVariant; 41 | } 42 | 43 | public all(): Variants { 44 | return {}; 45 | } 46 | 47 | public clear(): void {} 48 | 49 | public exposure(key: string): void {} 50 | } 51 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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 | flagKeys?: string[]; 7 | }; 8 | 9 | /** 10 | * Interface for the main client. 11 | * @category Core Usage 12 | */ 13 | export interface Client { 14 | start(user?: ExperimentUser): Promise; 15 | stop(): void; 16 | fetch(user?: ExperimentUser, options?: FetchOptions): Promise; 17 | variant(key: string, fallback?: string | Variant): Variant; 18 | all(): Variants; 19 | clear(): void; 20 | exposure(key: string): void; 21 | getUser(): ExperimentUser; 22 | setUser(user: ExperimentUser): void; 23 | 24 | /** 25 | * @deprecated use ExperimentConfig.userProvider instead 26 | */ 27 | getUserProvider(): ExperimentUserProvider; 28 | /** 29 | * @deprecated use ExperimentConfig.userProvider instead 30 | */ 31 | setUserProvider(userProvider: ExperimentUserProvider): Client; 32 | } 33 | -------------------------------------------------------------------------------- /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 | 50 | /** 51 | * Interface for enabling tracking {@link Exposure}s through the 52 | * {@link ExperimentClient}. 53 | * 54 | * If you're using the Amplitude Analytics SDK for tracking you do not need 55 | * to implement this interface. Simply initialize experiment using the 56 | * {@link Experiment.initializeWithAmplitudeAnalytics} function. 57 | * 58 | * If you're using a 3rd party analytics implementation then you'll need to 59 | * implement the sending of the analytics event yourself. The implementation 60 | * should result in the following event getting sent to amplitude: 61 | * 62 | * ``` 63 | * { 64 | * "event_type": "$exposure", 65 | * "event_properties": { 66 | * "flag_key": "", 67 | * "variant": "", 68 | * "experiment_key": "" 69 | * } 70 | * } 71 | * ``` 72 | * 73 | * For example, if you're using Segment for analytics: 74 | * 75 | * ``` 76 | * analytics.track('$exposure', exposure) 77 | * ``` 78 | */ 79 | export interface ExposureTrackingProvider { 80 | /** 81 | * Called when the {@link ExperimentClient} intends to track an exposure event; 82 | * either when {@link ExperimentClient.variant} serves a variant (and 83 | * {@link ExperimentConfig.automaticExposureTracking} is `true`) or if 84 | * {@link ExperimentClient.exposure} is called. 85 | * 86 | * The implementation should result in the following event getting sent to 87 | * amplitude: 88 | * 89 | * ``` 90 | * { 91 | * "event_type": "$exposure", 92 | * "event_properties": { 93 | * "flag_key": "", 94 | * "variant": "", 95 | * "experiment_key": "" 96 | * } 97 | * } 98 | * ``` 99 | * 100 | * For example, if you're using Segment for analytics: 101 | * 102 | * ``` 103 | * analytics.track('$exposure', exposure) 104 | * ``` 105 | */ 106 | track(exposure: Exposure): void; 107 | } 108 | -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /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-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/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-browser/src/types/user.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Defines a user context for evaluation. 3 | * `device_id` and `user_id` are used for identity resolution. 4 | * All other predefined fields and user properties are used for 5 | * rule based user targeting. 6 | * @category Types 7 | */ 8 | export type ExperimentUser = { 9 | /** 10 | * Device ID for associating with an identity in Amplitude 11 | */ 12 | device_id?: string; 13 | 14 | /** 15 | * User ID for associating with an identity in Amplitude 16 | */ 17 | user_id?: string; 18 | 19 | /** 20 | * Predefined field, can be manually provided 21 | */ 22 | country?: string; 23 | 24 | /** 25 | * Predefined field, can be manually provided 26 | */ 27 | city?: string; 28 | 29 | /** 30 | * Predefined field, can be manually provided 31 | */ 32 | region?: string; 33 | 34 | /** 35 | * Predefined field, can be manually provided 36 | */ 37 | dma?: string; 38 | 39 | /** 40 | * Predefined field, auto populated via a ExperimentUserProvider 41 | * or can be manually provided 42 | */ 43 | language?: string; 44 | 45 | /** 46 | * Predefined field, auto populated via a ExperimentUserProvider 47 | * or can be manually provided 48 | */ 49 | platform?: string; 50 | 51 | /** 52 | * Predefined field, auto populated via a ExperimentUserProvider 53 | * or can be manually provided 54 | */ 55 | version?: string; 56 | 57 | /** 58 | * Predefined field, auto populated via a ExperimentUserProvider 59 | * or can be manually provided 60 | */ 61 | os?: string; 62 | 63 | /** 64 | * Predefined field, auto populated via a ExperimentUserProvider 65 | * or can be manually provided 66 | */ 67 | device_model?: string; 68 | 69 | /** 70 | * Predefined field, can be manually provided 71 | */ 72 | carrier?: string; 73 | 74 | /** 75 | * Predefined field, auto populated, can be manually overridden 76 | */ 77 | library?: string; 78 | 79 | /** 80 | * Predefined field, can be manually provided 81 | */ 82 | ip_address?: string; 83 | 84 | /** 85 | * The time first saw this user, stored in local storage, can be manually overridden 86 | */ 87 | first_seen?: string; 88 | 89 | /** 90 | * The device category of the device, auto populated via a ExperimentUserProvider, can be manually overridden 91 | */ 92 | device_category?: 93 | | 'mobile' 94 | | 'tablet' 95 | | 'desktop' 96 | | 'wearable' 97 | | 'console' 98 | | 'smarttv' 99 | | 'embedded'; 100 | 101 | /** 102 | * The referring url that redirected to this page, auto populated via a ExperimentUserProvider, can be manually overridden 103 | */ 104 | referring_url?: string; 105 | 106 | /** 107 | * The cookies, auto populated via a ExperimentUserProvider, can be manually overridden 108 | * Local evaluation only. Stripped before remote evaluation. 109 | */ 110 | cookie?: Record; 111 | 112 | /** 113 | * The browser used, auto populated via a ExperimentUserProvider, can be manually overridden 114 | */ 115 | browser?: string; 116 | 117 | /** 118 | * The landing page of the user, the first page that this user sees for this deployment 119 | * Auto populated via a ExperimentUserProvider, can be manually overridden 120 | */ 121 | landing_url?: string; 122 | 123 | /** 124 | * The url params of the page, for one param, value is string if single value, array of string if multiple values 125 | * Auto populated via a ExperimentUserProvider, can be manually overridden 126 | */ 127 | url_param?: Record; 128 | 129 | /** 130 | * The user agent string. 131 | */ 132 | user_agent?: string; 133 | 134 | /** 135 | * Custom user properties 136 | */ 137 | user_properties?: { 138 | [propertyName: string]: 139 | | string 140 | | number 141 | | boolean 142 | | Array; 143 | }; 144 | 145 | groups?: { 146 | [groupType: string]: string[]; 147 | }; 148 | 149 | group_properties?: { 150 | [groupType: string]: { 151 | [groupName: string]: { 152 | [propertyName: string]: 153 | | string 154 | | number 155 | | boolean 156 | | Array; 157 | }; 158 | }; 159 | }; 160 | }; 161 | 162 | export type UserProperties = { 163 | [propertyName: string]: 164 | | string 165 | | number 166 | | boolean 167 | | Array; 168 | }; 169 | -------------------------------------------------------------------------------- /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-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/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-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-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 | export const isLocalEvaluationMode = ( 13 | flag: EvaluationFlag | undefined, 14 | ): boolean => { 15 | return flag?.metadata?.evaluationMode === 'local'; 16 | }; 17 | 18 | export const isRemoteEvaluationMode = ( 19 | flag: EvaluationFlag | undefined, 20 | ): boolean => { 21 | return flag?.metadata?.evaluationMode === 'remote'; 22 | }; 23 | -------------------------------------------------------------------------------- /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-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-browser/src/util/sessionExposureTrackingProvider.ts: -------------------------------------------------------------------------------- 1 | import { Exposure, ExposureTrackingProvider } from '../types/exposure'; 2 | 3 | export class SessionExposureTrackingProvider 4 | implements ExposureTrackingProvider 5 | { 6 | private readonly exposureTrackingProvider: ExposureTrackingProvider; 7 | private tracked: Record = {}; 8 | 9 | constructor(exposureTrackingProvider: ExposureTrackingProvider) { 10 | this.exposureTrackingProvider = exposureTrackingProvider; 11 | } 12 | 13 | track(exposure: Exposure): void { 14 | const trackedExposure = this.tracked[exposure.flag_key]; 15 | if (trackedExposure && trackedExposure.variant === exposure.variant) { 16 | return; 17 | } else { 18 | this.tracked[exposure.flag_key] = exposure; 19 | this.exposureTrackingProvider.track(exposure); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/experiment-browser/test/convert.test.ts: -------------------------------------------------------------------------------- 1 | import * as util from '@amplitude/experiment-core'; 2 | 3 | import { ExperimentUser } from '../src/types/user'; 4 | import { convertUserToContext } from '../src/util/convert'; 5 | 6 | describe('convertUserToContext', () => { 7 | beforeEach(() => { 8 | jest.spyOn(util, 'getGlobalScope').mockReturnValue(undefined); 9 | }); 10 | 11 | describe('groups', () => { 12 | test('undefined user', () => { 13 | const user: ExperimentUser | undefined = undefined; 14 | const context = convertUserToContext(user); 15 | expect(context).toEqual({}); 16 | }); 17 | test('undefined groups', () => { 18 | const user: ExperimentUser = {}; 19 | const context = convertUserToContext(user); 20 | expect(context).toEqual({ user: {} }); 21 | }); 22 | test('empty groups', () => { 23 | const user: ExperimentUser = { groups: {} }; 24 | const context = convertUserToContext(user); 25 | expect(context).toEqual({ user: {} }); 26 | }); 27 | test('groups and group_properties removed from user', () => { 28 | const user: ExperimentUser = { 29 | user_id: 'user_id', 30 | groups: {}, 31 | group_properties: {}, 32 | }; 33 | const context = convertUserToContext(user); 34 | expect(context).toEqual({ user: { user_id: 'user_id' } }); 35 | }); 36 | test('user groups, undefined group properties, moved under context groups', () => { 37 | const user: ExperimentUser = { 38 | user_id: 'user_id', 39 | groups: { gt1: ['gn1'] }, 40 | }; 41 | const context = convertUserToContext(user); 42 | expect(context).toEqual({ 43 | user: { user_id: 'user_id' }, 44 | groups: { gt1: { group_name: 'gn1' } }, 45 | }); 46 | }); 47 | test('user groups, empty group properties, moved under context groups', () => { 48 | const user: ExperimentUser = { 49 | user_id: 'user_id', 50 | groups: { gt1: ['gn1'] }, 51 | group_properties: {}, 52 | }; 53 | const context = convertUserToContext(user); 54 | expect(context).toEqual({ 55 | user: { user_id: 'user_id' }, 56 | groups: { gt1: { group_name: 'gn1' } }, 57 | }); 58 | }); 59 | test('user groups, group properties empty group type object, moved under context groups', () => { 60 | const user: ExperimentUser = { 61 | user_id: 'user_id', 62 | groups: { gt1: ['gn1'] }, 63 | group_properties: { gt1: {} }, 64 | }; 65 | const context = convertUserToContext(user); 66 | expect(context).toEqual({ 67 | user: { user_id: 'user_id' }, 68 | groups: { gt1: { group_name: 'gn1' } }, 69 | }); 70 | }); 71 | test('user groups, group properties empty group name object, moved under context groups', () => { 72 | const user: ExperimentUser = { 73 | user_id: 'user_id', 74 | groups: { gt1: ['gn1'] }, 75 | group_properties: { gt1: { gn1: {} } }, 76 | }; 77 | const context = convertUserToContext(user); 78 | expect(context).toEqual({ 79 | user: { user_id: 'user_id' }, 80 | groups: { gt1: { group_name: 'gn1' } }, 81 | }); 82 | }); 83 | test('user groups, with group properties, moved under context groups', () => { 84 | const user: ExperimentUser = { 85 | user_id: 'user_id', 86 | groups: { gt1: ['gn1'] }, 87 | group_properties: { gt1: { gn1: { gp1: 'gp1' } } }, 88 | }; 89 | const context = convertUserToContext(user); 90 | expect(context).toEqual({ 91 | user: { user_id: 'user_id' }, 92 | groups: { 93 | gt1: { group_name: 'gn1', group_properties: { gp1: 'gp1' } }, 94 | }, 95 | }); 96 | }); 97 | test('user groups and group properties, with multiple group names, takes first', () => { 98 | const user: ExperimentUser = { 99 | user_id: 'user_id', 100 | groups: { gt1: ['gn1', 'gn2'] }, 101 | group_properties: { gt1: { gn1: { gp1: 'gp1' }, gn2: { gp2: 'gp2' } } }, 102 | }; 103 | const context = convertUserToContext(user); 104 | expect(context).toEqual({ 105 | user: { user_id: 'user_id' }, 106 | groups: { 107 | gt1: { group_name: 'gn1', group_properties: { gp1: 'gp1' } }, 108 | }, 109 | }); 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /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/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-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-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 | 14 | class MockStorage implements Storage { 15 | private store = {}; 16 | delete(key: string): void { 17 | delete this.store[key]; 18 | } 19 | 20 | get(key: string): string { 21 | return this.store[key]; 22 | } 23 | 24 | put(key: string, value: string): void { 25 | this.store[key] = value; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /packages/experiment-core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | # [0.11.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.10.1...@amplitude/experiment-core@0.11.0) (2025-02-14) 7 | 8 | 9 | ### Features 10 | 11 | * Web experiment remote evaluation ([#138](https://github.com/amplitude/experiment-js-client/issues/138)) ([d7c167f](https://github.com/amplitude/experiment-js-client/commit/d7c167f2df625bd15b6a2af2c2cb01a5e1ccc108)) 12 | 13 | 14 | 15 | 16 | 17 | ## [0.10.1](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.10.0...@amplitude/experiment-core@0.10.1) (2024-12-02) 18 | 19 | **Note:** Version bump only for package @amplitude/experiment-core 20 | 21 | 22 | 23 | 24 | 25 | # [0.10.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.9.0...@amplitude/experiment-core@0.10.0) (2024-10-31) 26 | 27 | 28 | ### Features 29 | 30 | * add new `evaluateConditions` method to EvaluationEngine ([#136](https://github.com/amplitude/experiment-js-client/issues/136)) ([9566ac2](https://github.com/amplitude/experiment-js-client/commit/9566ac208a31b33bc1e6c34ad9bc8be1376bb745)) 31 | 32 | 33 | 34 | 35 | 36 | # [0.9.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.8.0...@amplitude/experiment-core@0.9.0) (2024-10-30) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * catch variant/flag fetch timeout error and log at debug-level ([#135](https://github.com/amplitude/experiment-js-client/issues/135)) ([879cfe3](https://github.com/amplitude/experiment-js-client/commit/879cfe327788e2e3c4a140840371868cfa62bcbc)) 42 | 43 | 44 | ### Features 45 | 46 | * add url param targeting ([#124](https://github.com/amplitude/experiment-js-client/issues/124)) ([aaad4fa](https://github.com/amplitude/experiment-js-client/commit/aaad4fa70788d8eabcfb34745957f57d01fe2a8e)) 47 | * Page targeting for Web Experimentation ([#117](https://github.com/amplitude/experiment-js-client/issues/117)) ([ab4ee1f](https://github.com/amplitude/experiment-js-client/commit/ab4ee1f3929b41903c353ba4499bbdcf0a7b27dc)) 48 | 49 | 50 | 51 | 52 | 53 | # [0.8.0](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.7.2...@amplitude/experiment-core@0.8.0) (2024-07-11) 54 | 55 | 56 | ### Features 57 | 58 | * add options evaluation api in experiment-core ([#114](https://github.com/amplitude/experiment-js-client/issues/114)) ([ce657a1](https://github.com/amplitude/experiment-js-client/commit/ce657a1fc9efdd28921ad12ccb702fb602a84c0c)) 59 | 60 | 61 | 62 | 63 | 64 | ## [0.7.2](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.7.1...@amplitude/experiment-core@0.7.2) (2024-01-29) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * Improve remote evaluation fetch retry logic ([#96](https://github.com/amplitude/experiment-js-client/issues/96)) ([9b8a559](https://github.com/amplitude/experiment-js-client/commit/9b8a559aed2ea1f594e0f1c94f14d64131ed7eb8)) 70 | 71 | 72 | 73 | 74 | 75 | ## [0.7.1](https://github.com/amplitude/experiment-js-client/compare/@amplitude/experiment-core@0.7.0...@amplitude/experiment-core@0.7.1) (2023-11-21) 76 | 77 | 78 | ### Bug Fixes 79 | 80 | * experiment-core types ([f8da426](https://github.com/amplitude/experiment-js-client/commit/f8da426f0f9ed1cc85afebe7ada6ec6819fa24d0)) 81 | 82 | 83 | 84 | 85 | 86 | # 0.7.0 (2023-08-29) 87 | 88 | 89 | ### Features 90 | 91 | * client-side local evaluation and core evaluation package ([#81](https://github.com/amplitude/experiment-js-client/issues/81)) ([91b24c5](https://github.com/amplitude/experiment-js-client/commit/91b24c56a92d38e87448084fc44d2c28005add60)) 92 | -------------------------------------------------------------------------------- /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-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-core", 3 | "version": "0.11.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-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-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'; 10 | 11 | export type GetVariantsOptions = { 12 | flagKeys?: string[]; 13 | trackingOption?: TrackingOption; 14 | deliveryMethod?: DeliveryMethod; 15 | evaluationMode?: EvaluationMode; 16 | timeoutMillis?: number; 17 | }; 18 | 19 | export interface EvaluationApi { 20 | getVariants( 21 | user: Record, 22 | options?: GetVariantsOptions, 23 | ): Promise>; 24 | } 25 | 26 | export class SdkEvaluationApi implements EvaluationApi { 27 | private readonly deploymentKey: string; 28 | private readonly serverUrl: string; 29 | private readonly httpClient: HttpClient; 30 | 31 | constructor( 32 | deploymentKey: string, 33 | serverUrl: string, 34 | httpClient: HttpClient, 35 | ) { 36 | this.deploymentKey = deploymentKey; 37 | this.serverUrl = serverUrl; 38 | this.httpClient = httpClient; 39 | } 40 | 41 | async getVariants( 42 | user: Record, 43 | options?: GetVariantsOptions, 44 | ): Promise> { 45 | const userJsonBase64 = Base64.encodeURL(JSON.stringify(user)); 46 | const headers: Record = { 47 | Authorization: `Api-Key ${this.deploymentKey}`, 48 | 'X-Amp-Exp-User': userJsonBase64, 49 | }; 50 | if (options?.flagKeys) { 51 | headers['X-Amp-Exp-Flag-Keys'] = Base64.encodeURL( 52 | JSON.stringify(options.flagKeys), 53 | ); 54 | } 55 | if (options?.trackingOption) { 56 | headers['X-Amp-Exp-Track'] = options.trackingOption; 57 | } 58 | const url = new URL(`${this.serverUrl}/sdk/v2/vardata?v=0`); 59 | if (options?.evaluationMode) { 60 | url.searchParams.append('eval_mode', options?.evaluationMode); 61 | } 62 | if (options?.deliveryMethod) { 63 | url.searchParams.append('delivery_method', options?.deliveryMethod); 64 | } 65 | const response = await this.httpClient.request({ 66 | requestUrl: url.toString(), 67 | method: 'GET', 68 | headers: headers, 69 | timeoutMillis: options?.timeoutMillis, 70 | }); 71 | if (response.status != 200) { 72 | throw new FetchError( 73 | response.status, 74 | `Fetch error response: status=${response.status}`, 75 | ); 76 | } 77 | return JSON.parse(response.body); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /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-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 | -------------------------------------------------------------------------------- /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-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-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) { 15 | return undefined; 16 | } else { 17 | return selectable; 18 | } 19 | }; 20 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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-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/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-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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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-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/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-tag", 3 | "version": "0.6.3", 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 | }, 25 | "bugs": { 26 | "url": "https://github.com/amplitude/experiment-js-client/issues" 27 | }, 28 | "dependencies": { 29 | "@amplitude/experiment-core": "^0.11.0", 30 | "@amplitude/experiment-js-client": "^1.15.6", 31 | "dom-mutator": "git+ssh://git@github.com:amplitude/dom-mutator#main", 32 | "rollup-plugin-license": "^3.6.0" 33 | }, 34 | "devDependencies": { 35 | "@rollup/plugin-terser": "^0.4.4", 36 | "rollup-plugin-license": "^3.6.0" 37 | }, 38 | "files": [ 39 | "dist/**/*" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /packages/experiment-tag/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { join, resolve as pathResolve } from 'path'; 2 | 3 | import tsConfig from '@amplitude/experiment-js-client/tsconfig.json'; 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 terser from '@rollup/plugin-terser'; 9 | import typescript from '@rollup/plugin-typescript'; 10 | import analyze from 'rollup-plugin-analyzer'; 11 | import license from 'rollup-plugin-license'; 12 | 13 | import * as packageJson from './package.json'; 14 | 15 | const getCommonBrowserConfig = (target) => ({ 16 | input: 'src/index.ts', 17 | treeshake: { 18 | moduleSideEffects: 'no-external', 19 | }, 20 | plugins: [ 21 | resolve(), 22 | json(), 23 | commonjs(), 24 | typescript({ 25 | ...(target === 'es2015' 26 | ? { target: 'es2015', downlevelIteration: true } 27 | : { downlevelIteration: true }), 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 | license({ 45 | thirdParty: { 46 | output: join(__dirname, 'dist', 'LICENSES'), 47 | }, 48 | }), 49 | ], 50 | }); 51 | 52 | const getOutputConfig = (outputOptions) => ({ 53 | output: { 54 | dir: 'dist', 55 | name: 'Experiment-Tag', 56 | banner: `/* ${packageJson.name} v${packageJson.version} - For license info see https://unpkg.com/@amplitude/experiment-tag@${packageJson.version}/LICENSES */`, 57 | ...outputOptions, 58 | }, 59 | }); 60 | 61 | const config = getCommonBrowserConfig('es5'); 62 | const configs = [ 63 | // legacy build for field "main" - ie8, umd, es5 syntax 64 | { 65 | ...config, 66 | ...getOutputConfig({ 67 | entryFileNames: 'experiment-tag.umd.js', 68 | exports: 'named', 69 | format: 'umd', 70 | }), 71 | plugins: [ 72 | ...config.plugins, 73 | terser({ 74 | format: { 75 | // Don't remove semver comment 76 | comments: 77 | /@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-]+)*))?/, 78 | }, 79 | }), // Apply terser plugin for minification 80 | ], 81 | external: [], 82 | }, 83 | ]; 84 | 85 | export default configs; 86 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/config.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentConfig } from '@amplitude/experiment-js-client'; 2 | 3 | export interface WebExperimentConfig extends ExperimentConfig { 4 | /** 5 | * Determines whether the default implementation for handling navigation {@link setDefaultUrlChangeHandler} will be used 6 | * If this is set to false, for single-page applications: 7 | * 1. The variant actions applied will be based on the context (user, page URL) when the web experiment script was loaded 8 | * 2. Custom handling of navigation should be implemented such that variant actions applied on the site reflect the latest context 9 | */ 10 | useDefaultNavigationHandler?: boolean; 11 | } 12 | 13 | export const Defaults: WebExperimentConfig = { 14 | useDefaultNavigationHandler: true, 15 | }; 16 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/index.ts: -------------------------------------------------------------------------------- 1 | import { DefaultWebExperimentClient } from './experiment'; 2 | 3 | const API_KEY = '{{DEPLOYMENT_KEY}}'; 4 | const initialFlags = '{{INITIAL_FLAGS}}'; 5 | const serverZone = '{{SERVER_ZONE}}'; 6 | const pageObjects = '{{PAGE_OBJECTS}}'; 7 | 8 | DefaultWebExperimentClient.getInstance(API_KEY, initialFlags, pageObjects, { 9 | serverZone: serverZone, 10 | }) 11 | .start() 12 | .then(() => { 13 | // Remove anti-flicker css if it exists 14 | document.getElementById('amp-exp-css')?.remove(); 15 | }); 16 | 17 | export { WebExperimentClient } from 'web-experiment'; 18 | export { WebExperimentConfig } from 'config'; 19 | export { 20 | ApplyVariantsOptions, 21 | RevertVariantsOptions, 22 | PreviewVariantsOptions, 23 | } from 'types'; 24 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/inject-utils.ts: -------------------------------------------------------------------------------- 1 | export interface InjectUtils { 2 | /** 3 | * Returns a promise that is resolved when an element matching the selector 4 | * is found in DOM. 5 | * 6 | * @param selector The element selector to query for. 7 | */ 8 | waitForElement(selector: string): Promise; 9 | 10 | /** 11 | * Function which can be set inside injected javascript code. This function is 12 | * called on page change, when experiments are re-evaluated. 13 | * 14 | * Useful for cleaning up changes to the page that have been made in single 15 | * page apps, where page the page is not fully reloaded. For example, if you 16 | * inject an HTML element on a specific page, you can set this function to 17 | * remove the injected element on page change. 18 | */ 19 | remove: (() => void) | undefined; 20 | } 21 | 22 | export const getInjectUtils = (): InjectUtils => 23 | ({ 24 | async waitForElement(selector: string): Promise { 25 | // If selector found in DOM, then return directly. 26 | const elem = document.querySelector(selector); 27 | if (elem) { 28 | return elem; 29 | } 30 | 31 | return new Promise((resolve) => { 32 | // An observer that is listening for all DOM mutation events. 33 | const observer = new MutationObserver(() => { 34 | const elem = document.querySelector(selector); 35 | if (elem) { 36 | observer.disconnect(); 37 | resolve(elem); 38 | } 39 | }); 40 | 41 | // Observe on all document changes. 42 | observer.observe(document.documentElement, { 43 | childList: true, 44 | subtree: true, 45 | attributes: true, 46 | }); 47 | }); 48 | }, 49 | } as InjectUtils); 50 | -------------------------------------------------------------------------------- /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/experiment-tag/src/messenger.ts: -------------------------------------------------------------------------------- 1 | import { getGlobalScope } from '@amplitude/experiment-core'; 2 | 3 | export class WindowMessenger { 4 | static setup() { 5 | let state: 'closed' | 'opening' | 'open' = 'closed'; 6 | getGlobalScope()?.addEventListener( 7 | 'message', 8 | ( 9 | e: MessageEvent<{ 10 | type: string; 11 | context: { injectSrc: string }; 12 | }>, 13 | ) => { 14 | const match = /^.*\.amplitude\.com$/; 15 | try { 16 | if (!e.origin || !match.test(new URL(e.origin).hostname)) { 17 | return; 18 | } 19 | } catch { 20 | // The security check failed on exception, return without throwing. 21 | // new URL(e.origin) can throw. 22 | return; 23 | } 24 | if (e.data.type === 'OpenOverlay') { 25 | if ( 26 | state !== 'closed' || 27 | !match.test(new URL(e.data.context.injectSrc).hostname) 28 | ) { 29 | return; 30 | } 31 | state = 'opening'; 32 | asyncLoadScript(e.data.context.injectSrc) 33 | .then(() => { 34 | state = 'open'; 35 | }) 36 | .catch(() => { 37 | state = 'closed'; 38 | }); 39 | } 40 | }, 41 | ); 42 | } 43 | } 44 | 45 | export const asyncLoadScript = (url: string) => { 46 | return new Promise((resolve, reject) => { 47 | try { 48 | const scriptElement = document.createElement('script'); 49 | scriptElement.type = 'text/javascript'; 50 | scriptElement.async = true; 51 | scriptElement.src = url; 52 | // Set the script nonce if it exists 53 | // This is useful for CSP (Content Security Policy) to allow the script to be loaded 54 | const nonceElem = document.querySelector('[nonce]'); 55 | if (nonceElem) { 56 | scriptElement.setAttribute( 57 | 'nonce', 58 | nonceElem['nonce'] || 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | (nonceElem as any).nonce || 61 | nonceElem.getAttribute('nonce'), 62 | ); 63 | } 64 | scriptElement.addEventListener( 65 | 'load', 66 | () => { 67 | resolve({ status: true }); 68 | }, 69 | { once: true }, 70 | ); 71 | scriptElement.addEventListener('error', () => { 72 | reject({ 73 | status: false, 74 | message: `Failed to load the script ${url}`, 75 | }); 76 | }); 77 | document.head?.appendChild(scriptElement); 78 | } catch (error) { 79 | reject(error); 80 | } 81 | }); 82 | }; 83 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/types.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationCondition } from '@amplitude/experiment-core'; 2 | 3 | import { MessageType } from './message-bus'; 4 | 5 | export type ApplyVariantsOptions = { 6 | /** 7 | * A list of flag keys to apply. 8 | */ 9 | flagKeys?: string[]; 10 | }; 11 | 12 | export type RevertVariantsOptions = { 13 | /** 14 | * A list of flag keys to revert. 15 | */ 16 | flagKeys?: string[]; 17 | }; 18 | 19 | export type PreviewVariantsOptions = { 20 | /** 21 | * A map of flag keys to variant keys to be previewed. 22 | */ 23 | keyToVariant?: Record; 24 | }; 25 | 26 | export type PageObject = { 27 | id: string; 28 | name: string; 29 | conditions?: EvaluationCondition[][]; 30 | trigger_type: MessageType; 31 | trigger_value: Record; 32 | }; 33 | 34 | export type PageObjects = { [flagKey: string]: { [id: string]: PageObject } }; 35 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/util.ts: -------------------------------------------------------------------------------- 1 | import { EvaluationVariant, getGlobalScope } from '@amplitude/experiment-core'; 2 | import { Variant } from '@amplitude/experiment-js-client'; 3 | 4 | export const getUrlParams = (): Record => { 5 | const globalScope = getGlobalScope(); 6 | const searchParams = new URLSearchParams(globalScope?.location.search); 7 | const params: Record = {}; 8 | for (const [key, value] of searchParams) { 9 | params[key] = value; 10 | } 11 | return params; 12 | }; 13 | 14 | export const urlWithoutParamsAndAnchor = (url: string): string => { 15 | if (!url) { 16 | return ''; 17 | } 18 | const urlObj = new URL(url); 19 | urlObj.search = ''; 20 | urlObj.hash = ''; 21 | return urlObj.toString(); 22 | }; 23 | 24 | export const removeQueryParams = ( 25 | url: string, 26 | paramsToRemove: string[], 27 | ): string => { 28 | const urlObj = new URL(url); 29 | for (const param of paramsToRemove) { 30 | urlObj.searchParams.delete(param); 31 | } 32 | return urlObj.toString(); 33 | }; 34 | 35 | export const UUID = function (a?: any): string { 36 | return a // if the placeholder was passed, return 37 | ? // a random number from 0 to 15 38 | ( 39 | a ^ // unless b is 8, 40 | ((Math.random() * // in which case 41 | 16) >> // a random number from 42 | (a / 4)) 43 | ) // 8 to 11 44 | .toString(16) // in hexadecimal 45 | : // or otherwise a concatenated string: 46 | ( 47 | String(1e7) + // 10000000 + 48 | String(-1e3) + // -1000 + 49 | String(-4e3) + // -4000 + 50 | String(-8e3) + // -80000000 + 51 | String(-1e11) 52 | ) // -100000000000, 53 | .replace( 54 | // replacing 55 | /[018]/g, // zeroes, ones, and eights with 56 | UUID, // random hex digits 57 | ); 58 | }; 59 | 60 | export const matchesUrl = (urlArray: string[], urlString: string): boolean => { 61 | urlString = urlString.replace(/\/$/, ''); 62 | 63 | return urlArray.some((url) => { 64 | url = url.replace(/\/$/, ''); // remove trailing slash 65 | url = url.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // escape url for regex 66 | url = url.replace(/\\\*/, '.*'); // replace escaped * with .* 67 | const regex = new RegExp(`^${url}$`); 68 | // Check regex match with and without trailing slash. For example, 69 | // `https://example.com/*` would not match `https://example.com` without 70 | // this addition. 71 | return regex.test(urlString) || regex.test(urlString + '/'); 72 | }); 73 | }; 74 | 75 | export const concatenateQueryParamsOf = ( 76 | currentUrl: string, 77 | redirectUrl: string, 78 | ): string => { 79 | const globalUrlObj = new URL(currentUrl); 80 | const redirectUrlObj = new URL(redirectUrl); 81 | const resultUrlObj = new URL(redirectUrl); 82 | 83 | globalUrlObj.searchParams.forEach((value, key) => { 84 | if (!redirectUrlObj.searchParams.has(key)) { 85 | resultUrlObj.searchParams.append(key, value); 86 | } 87 | }); 88 | 89 | return resultUrlObj.toString(); 90 | }; 91 | 92 | export const convertEvaluationVariantToVariant = ( 93 | evaluationVariant: EvaluationVariant, 94 | ): Variant => { 95 | if (!evaluationVariant) { 96 | return {}; 97 | } 98 | let experimentKey: string | undefined = undefined; 99 | if (evaluationVariant.metadata) { 100 | if (typeof evaluationVariant.metadata['experimentKey'] === 'string') { 101 | experimentKey = evaluationVariant.metadata['experimentKey']; 102 | } else { 103 | experimentKey = undefined; 104 | } 105 | } 106 | const variant: Variant = {}; 107 | if (evaluationVariant.key) variant.key = evaluationVariant.key; 108 | if (evaluationVariant.value) 109 | variant.value = evaluationVariant.value as string; 110 | if (evaluationVariant.payload) variant.payload = evaluationVariant.payload; 111 | if (experimentKey) variant.expKey = experimentKey; 112 | if (evaluationVariant.metadata) variant.metadata = evaluationVariant.metadata; 113 | return variant; 114 | }; 115 | -------------------------------------------------------------------------------- /packages/experiment-tag/src/web-experiment.ts: -------------------------------------------------------------------------------- 1 | import { ExperimentClient, Variants } from '@amplitude/experiment-js-client'; 2 | 3 | import { 4 | ApplyVariantsOptions, 5 | PageObjects, 6 | PreviewVariantsOptions, 7 | RevertVariantsOptions, 8 | } from './types'; 9 | 10 | /** 11 | * Interface for the Web Experiment client. 12 | */ 13 | 14 | export interface WebExperimentClient { 15 | start(): void; 16 | 17 | getExperimentClient(): ExperimentClient; 18 | 19 | applyVariants(options?: ApplyVariantsOptions): void; 20 | 21 | revertVariants(options?: RevertVariantsOptions): void; 22 | 23 | previewVariants(options: PreviewVariantsOptions): void; 24 | 25 | getVariants(): Variants; 26 | 27 | getActiveExperiments(): string[]; 28 | 29 | getActivePages(): PageObjects; 30 | 31 | setRedirectHandler(handler: (url: string) => void): void; 32 | } 33 | -------------------------------------------------------------------------------- /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 | : []; 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: [], 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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/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/plugin-segment/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@amplitude/experiment-plugin-segment", 3 | "version": "0.2.17", 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.umd.js", 10 | "module": "dist/experiment-plugin-segment.esm.js", 11 | "es2015": "dist/experiment-plugin-segment.es2015.js", 12 | "types": "dist/types/src/index.d.ts", 13 | "publishConfig": { 14 | "access": "public" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "https://github.com/amplitude/experiment-js-client.git", 19 | "directory": "packages/plugin-segment" 20 | }, 21 | "scripts": { 22 | "build": "rm -rf dist && rollup -c", 23 | "clean": "rimraf node_modules dist", 24 | "lint": "eslint . --ignore-path ../../.eslintignore && prettier -c . --ignore-path ../../.prettierignore", 25 | "test": "jest", 26 | "prepublish": "yarn build" 27 | }, 28 | "bugs": { 29 | "url": "https://github.com/amplitude/experiment-js-client/issues" 30 | }, 31 | "dependencies": { 32 | "@amplitude/experiment-js-client": "^1.15.6", 33 | "@segment/analytics-next": "^1.73.0" 34 | }, 35 | "devDependencies": { 36 | "@rollup/plugin-terser": "^0.4.4" 37 | }, 38 | "files": [ 39 | "dist" 40 | ] 41 | } 42 | -------------------------------------------------------------------------------- /packages/plugin-segment/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 | 12 | import * as packageJson from './package.json'; 13 | import tsConfig from './tsconfig.json'; 14 | 15 | const getCommonBrowserConfig = (target) => ({ 16 | input: 'src/index.ts', 17 | treeshake: { 18 | moduleSideEffects: 'no-external', 19 | }, 20 | plugins: [ 21 | replace({ 22 | preventAssignment: true, 23 | BUILD_BROWSER: true, 24 | }), 25 | resolve(), 26 | json(), 27 | commonjs(), 28 | typescript({ 29 | ...(target === 'es2015' ? { target: 'es2015' } : {}), 30 | declaration: true, 31 | declarationDir: 'dist/types', 32 | include: tsConfig.include, 33 | rootDir: '.', 34 | }), 35 | babel({ 36 | configFile: 37 | target === 'es2015' 38 | ? pathResolve(__dirname, '../..', 'babel.es2015.config.js') 39 | : undefined, 40 | babelHelpers: 'bundled', 41 | exclude: ['node_modules/**'], 42 | }), 43 | analyze({ 44 | summaryOnly: true, 45 | }), 46 | ], 47 | }); 48 | 49 | const getOutputConfig = (outputOptions) => ({ 50 | output: { 51 | dir: 'dist', 52 | name: 'Experiment', 53 | ...outputOptions, 54 | }, 55 | }); 56 | 57 | const configs = [ 58 | // minified build 59 | { 60 | ...getCommonBrowserConfig('es5'), 61 | ...getOutputConfig({ 62 | entryFileNames: 'experiment-plugin-segment.min.js', 63 | exports: 'named', 64 | format: 'umd', 65 | banner: `/* ${packageJson.name} v${packageJson.version} */`, 66 | }), 67 | plugins: [ 68 | ...getCommonBrowserConfig('es5').plugins, 69 | terser({ 70 | format: { 71 | // Don't remove semver comment 72 | comments: 73 | /@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-]+)*))?/, 74 | }, 75 | }), // Apply terser plugin for minification 76 | ], 77 | external: [], 78 | }, 79 | 80 | // legacy build for field "main" - ie8, umd, es5 syntax 81 | { 82 | ...getCommonBrowserConfig('es5'), 83 | ...getOutputConfig({ 84 | entryFileNames: 'experiment-plugin-segment.umd.js', 85 | exports: 'named', 86 | format: 'umd', 87 | }), 88 | external: [], 89 | }, 90 | 91 | // tree shakable build for field "module" - ie8, esm, es5 syntax 92 | { 93 | ...getCommonBrowserConfig('es5'), 94 | ...getOutputConfig({ 95 | entryFileNames: 'experiment-plugin-segment.esm.js', 96 | format: 'esm', 97 | }), 98 | external: [], 99 | }, 100 | 101 | // modern build for field "es2015" - not ie, esm, es2015 syntax 102 | { 103 | ...getCommonBrowserConfig('es2015'), 104 | ...getOutputConfig({ 105 | entryFileNames: 'experiment-plugin-segment.es2015.js', 106 | format: 'esm', 107 | }), 108 | external: [], 109 | }, 110 | ]; 111 | 112 | export default configs; 113 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /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/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/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/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/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 | -------------------------------------------------------------------------------- /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 | --------------------------------------------------------------------------------