├── .node-version ├── website ├── static │ ├── .nojekyll │ └── img │ │ ├── image.png │ │ ├── amp_logo.png │ │ ├── image.webp │ │ ├── 404monster.png │ │ ├── amp_favicon.ico │ │ └── amp_logo.svg ├── babel.config.js ├── sidebars.js ├── .gitignore ├── src │ ├── pages │ │ └── styles.module.css │ └── css │ │ └── custom.css ├── package.json ├── README.md ├── docusaurus.config.js └── generate-jsdoc.js ├── .eslintignore ├── .prettierignore ├── .prettierrc.json ├── test ├── browser │ ├── .eslintrc.json │ ├── mocha-tests.html │ ├── snippet.html │ ├── server.js │ ├── index.html │ ├── amplitudejs-segment.html │ ├── amplitudejs-requirejs.html │ ├── amplitudejs2.html │ ├── mocha.css │ └── amplitudejs.html ├── global-scope.js ├── uuid.js ├── tests.js ├── md5.js ├── base64Id.js ├── config-manager.js ├── top-domain.js ├── server-zone.js ├── base64.js ├── mock-cookie.js ├── cookie.js ├── utm.js ├── worker-storage.js ├── language.js ├── cookiestorage.js ├── snippet-tests.js ├── revenue.js ├── base-cookie.js ├── web-worker.js └── utils.js ├── .babelrc ├── .github ├── ISSUE_TEMPLATE │ ├── Question.md │ ├── Feature_request.md │ └── Bug_report.md ├── semantic.yml ├── pull_request_template.md └── workflows │ ├── jira-issue-create.yml │ ├── lint.yml │ ├── test.yml │ ├── deploy-docs.yml │ ├── publish-github-packages.yml │ ├── codeql-analysis.yml │ ├── release.yml │ └── semantic-pr.yml ├── rollup.test.js ├── rollup.min.js ├── rollup.umd.min.js ├── src ├── language.js ├── global-scope.js ├── base64Id.js ├── worker-storage.js ├── top-domain.js ├── uuid.js ├── index.js ├── type.js ├── server-zone.js ├── utm.js ├── utf8.js ├── config-manager.js ├── constants.js ├── xhr.js ├── cookiestorage.js ├── cookie.js ├── base64.js ├── base-cookie.js ├── localstorage.js ├── amplitude-snippet.js ├── revenue.js ├── metadata-storage.js ├── utils.js └── identify.js ├── rollup.snippet-tests.js ├── .npmignore ├── .gitignore ├── scripts ├── create-snippet-instructions.js ├── readme.js ├── release.js ├── version.js └── deploy_s3.py ├── bower.json ├── .vscode └── settings.json ├── rollup.esm.js ├── .eslintrc.json ├── karma-web-worker.conf.js ├── rollup.config.js ├── rollup.umd.js ├── release.config.js ├── LICENSE ├── karma.conf.js ├── Makefile ├── CONTRIBUTING.md ├── package.json └── README.md /.node-version: -------------------------------------------------------------------------------- 1 | 14.4.0 2 | -------------------------------------------------------------------------------- /website/static/.nojekyll: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /*.js 2 | /website/.docusaurus/ 3 | /website/build/ 4 | /build/ 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | /*.js 3 | /website/.docusaurus/ 4 | /website/build/ 5 | /build/ 6 | -------------------------------------------------------------------------------- /website/static/img/image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/Amplitude-JavaScript/HEAD/website/static/img/image.png -------------------------------------------------------------------------------- /website/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [require.resolve('@docusaurus/core/lib/babel/preset')], 3 | }; 4 | -------------------------------------------------------------------------------- /website/static/img/amp_logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/Amplitude-JavaScript/HEAD/website/static/img/amp_logo.png -------------------------------------------------------------------------------- /website/static/img/image.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/Amplitude-JavaScript/HEAD/website/static/img/image.webp -------------------------------------------------------------------------------- /website/static/img/404monster.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/Amplitude-JavaScript/HEAD/website/static/img/404monster.png -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "proseWrap": "always", 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /website/static/img/amp_favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amplitude/Amplitude-JavaScript/HEAD/website/static/img/amp_favicon.ico -------------------------------------------------------------------------------- /test/browser/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | "prettier/prettier": "error", 4 | "no-prototype-builtins": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /website/sidebars.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | sidebar: { 3 | 'API Reference': ['AmplitudeClient', 'Amplitude', 'Identify', 'Revenue', 'Options'], 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "browsers": ["ie >= 8"] 6 | }, 7 | "modules": false 8 | }] 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question ❓ 3 | about: Ask a question 4 | labels: 'question' 5 | --- 6 | 7 | ## Summary 8 | 9 | 10 | -------------------------------------------------------------------------------- /rollup.test.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config.js'; 2 | 3 | config.input = 'test/tests.js'; 4 | config.output.file = 'build/tests.js'; 5 | config.output.sourcemap = true; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /rollup.min.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config.js'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | config.plugins.push(terser()); 5 | config.output.file = 'amplitude.min.js'; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /rollup.umd.min.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.umd.js'; 2 | import terser from '@rollup/plugin-terser'; 3 | 4 | config.plugins.push(terser()); 5 | config.output.file = 'amplitude.umd.min.js'; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /test/global-scope.js: -------------------------------------------------------------------------------- 1 | import GlobalScope from '../src/global-scope'; 2 | 3 | describe('GlobalScope', function () { 4 | it('should return true', function () { 5 | assert.isTrue(GlobalScope === window); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /src/language.js: -------------------------------------------------------------------------------- 1 | var getLanguage = function () { 2 | return ( 3 | (typeof navigator !== 'undefined' && 4 | ((navigator.languages && navigator.languages[0]) || navigator.language || navigator.userLanguage)) || 5 | '' 6 | ); 7 | }; 8 | 9 | export default { 10 | getLanguage, 11 | }; 12 | -------------------------------------------------------------------------------- /rollup.snippet-tests.js: -------------------------------------------------------------------------------- 1 | import config from './rollup.config.js'; 2 | import legacy from '@rollup/plugin-legacy'; 3 | 4 | config.plugins.push(legacy({ 5 | './amplitude-snippet.min.js': 'amplitude', 6 | })); 7 | config.input = 'test/snippet-tests.js'; 8 | config.output.file = 'build/snippet-tests.js'; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | components 2 | documentation 3 | npm-debug.log 4 | build 5 | dist 6 | .DS_Store 7 | rollup.* 8 | circle.yml 9 | dist 10 | Makefile 11 | bower.json 12 | test 13 | build 14 | karma.conf.js 15 | scripts 16 | .npmignore 17 | .prettierrc.json 18 | .eslintrc.json 19 | amplitude.min.js 20 | amplitude-segment-snippet.min.js 21 | *.log 22 | src 23 | .watchmanconfig 24 | .babelrc 25 | -------------------------------------------------------------------------------- /src/global-scope.js: -------------------------------------------------------------------------------- 1 | /* global globalThis */ 2 | const GlobalScope = (() => { 3 | if (typeof globalThis !== 'undefined') { 4 | return globalThis; 5 | } 6 | if (typeof window !== 'undefined') { 7 | return window; 8 | } 9 | if (typeof self !== 'undefined') { 10 | return self; 11 | } 12 | if (typeof global !== 'undefined') { 13 | return global; 14 | } 15 | })(); 16 | 17 | export default GlobalScope; 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 🚀 3 | about: You'd like something added to the SDK 4 | labels: 'enhancement' 5 | --- 6 | 7 | 8 | 9 | ## Summary 10 | 11 | 12 | 13 | ## Motivations 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/semantic.yml: -------------------------------------------------------------------------------- 1 | # Validate the PR title, and ignore the commits 2 | titleOnly: true 3 | 4 | # By default types specified in commitizen/conventional-commit-types is used. 5 | # See: https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json 6 | # You can override the valid types 7 | 8 | types: 9 | - feat 10 | - fix 11 | - perf 12 | - docs 13 | - test 14 | - refactor 15 | - style 16 | - build 17 | - ci 18 | - chore 19 | - revert 20 | -------------------------------------------------------------------------------- /src/base64Id.js: -------------------------------------------------------------------------------- 1 | // A URL safe variation on the the list of Base64 characters 2 | const base64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; 3 | 4 | const base64Id = () => { 5 | const randomValues = crypto.getRandomValues(new Uint8Array(22)); 6 | 7 | let str = ''; 8 | 9 | for (let i = 0; i < 22; ++i) { 10 | str += base64Chars.charAt(randomValues[i] % 64); 11 | } 12 | 13 | return str; 14 | }; 15 | 16 | export default base64Id; 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | components 3 | npm-debug.log 4 | build 5 | dist 6 | .DS_Store 7 | *.crt 8 | *.key 9 | amplitude.js 10 | amplitude.esm.js 11 | amplitude.min.js 12 | amplitude-snippet.min.js 13 | amplitude-snippet-instructions.js 14 | amplitude-segment-snippet.min.js 15 | .watchmanconfig 16 | package-lock.json 17 | amplitude.umd.js 18 | amplitude.umd.min.js 19 | amplitude.native.js 20 | amplitude.nocompat.js 21 | amplitude.nocompat.min.js 22 | 23 | # For WebStorm IDE 24 | .idea/ 25 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Generated jsdoc markdown 12 | docs/Amplitude.md 13 | docs/AmplitudeClient.md 14 | docs/Identify.md 15 | docs/Revenue.md 16 | docs/Options.md 17 | 18 | # Misc 19 | .DS_Store 20 | .env.local 21 | .env.development.local 22 | .env.test.local 23 | .env.production.local 24 | 25 | npm-debug.log* 26 | yarn-debug.log* 27 | yarn-error.log* 28 | -------------------------------------------------------------------------------- /.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/Amplitude-JavaScript/blob/main/CONTRIBUTING.md#pr-commit-title-conventions)? 14 | * Does your PR have a breaking change?: 15 | -------------------------------------------------------------------------------- /test/uuid.js: -------------------------------------------------------------------------------- 1 | import UUID from '../src/uuid.js'; 2 | 3 | describe('UUID', function () { 4 | it('should generate a valid UUID-4', function () { 5 | var uuid = UUID(); 6 | assert.equal(36, uuid.length); 7 | assert.equal('4', uuid.substring(14, 15)); 8 | }); 9 | 10 | it('should generate a unique UUID-4', () => { 11 | const ids = new Set(); 12 | const count = 10000; 13 | for (let i = 0; i < count; i++) { 14 | ids.add(UUID()); 15 | } 16 | assert.equal(ids.size, count); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /test/tests.js: -------------------------------------------------------------------------------- 1 | import './base64.js'; 2 | import './language.js'; 3 | import './md5.js'; 4 | import './uuid.js'; 5 | import './cookie.js'; 6 | import './ua-parser.js'; 7 | import './identify.js'; 8 | import './cookiestorage.js'; 9 | import './utm.js'; 10 | import './amplitude.js'; 11 | import './amplitude-client.js'; 12 | import './utils.js'; 13 | import './revenue.js'; 14 | import './base-cookie.js'; 15 | import './top-domain.js'; 16 | import './base64Id.js'; 17 | import './server-zone.js'; 18 | import './config-manager.js'; 19 | import './worker-storage.js'; 20 | import './global-scope.js'; 21 | -------------------------------------------------------------------------------- /scripts/create-snippet-instructions.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | 4 | const cwd = process.cwd(); 5 | const instructionsFileName = path.join(cwd, 'amplitude-snippet-instructions.js'); 6 | 7 | const snippetFilename = path.join(cwd, 'amplitude-snippet.min.js'); 8 | const snippet = fs.readFileSync(snippetFilename, 'utf-8'); 9 | 10 | const script = ` 14 | `; 15 | fs.writeFileSync(instructionsFileName, script); 16 | 17 | console.log('Created amplitude-snippet-instructions.js'); 18 | -------------------------------------------------------------------------------- /test/browser/mocha-tests.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Amplitude Tests 6 | 7 | 8 | 9 |
10 | 11 | 16 | 17 | 18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amplitude-js", 3 | "description": "Javascript library for Amplitude Analytics", 4 | "main": "amplitude.umd.js", 5 | "authors": [ 6 | "Amplitude " 7 | ], 8 | "license": "MIT", 9 | "keywords": [ 10 | "analytics", 11 | "amplitude" 12 | ], 13 | "homepage": "https://github.com/amplitude/Amplitude-JavaScript", 14 | "ignore": [ 15 | "**/.*", 16 | "Makefile", 17 | "circle.yml", 18 | "build", 19 | "components", 20 | "dist", 21 | "karma.conf.js", 22 | "node_modules", 23 | "scripts", 24 | "package-lock.json", 25 | "webpack.*.js", 26 | "bower_components", 27 | "test" 28 | ] 29 | } 30 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll": true 4 | }, 5 | "editor.defaultFormatter": "esbenp.prettier-vscode", 6 | "editor.formatOnType": true, 7 | "editor.formatOnPaste": false, 8 | "editor.formatOnSave": true, 9 | "editor.rulers": [120], 10 | "editor.tabSize": 2, 11 | "files.autoSave": "onWindowChange", 12 | "files.trimTrailingWhitespace": true, 13 | "files.insertFinalNewline": true, 14 | "search.exclude": { 15 | "**/node_modules/": true, 16 | "**/build/": true, 17 | "**/dist/": true 18 | }, 19 | "[json]": { 20 | "editor.formatOnType": false, 21 | "editor.formatOnPaste": false, 22 | "editor.formatOnSave": false 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/jira-issue-create.yml: -------------------------------------------------------------------------------- 1 | # Creates jira tickets for new github issues to help triage 2 | name: Jira Issue Creator For JS 3 | 4 | on: 5 | issues: 6 | types: [opened] 7 | workflow_call: 8 | inputs: 9 | label: 10 | type: string 11 | 12 | jobs: 13 | call-workflow-passing-data: 14 | uses: amplitude/Amplitude-TypeScript/.github/workflows/jira-issue-create-template.yml@main 15 | with: 16 | label: 'JS' 17 | subcomponent: "dx_javascript_sdk" 18 | secrets: 19 | JIRA_BASE_URL: ${{ secrets.JIRA_BASE_URL }} 20 | JIRA_USER_EMAIL: ${{ secrets.JIRA_USER_EMAIL }} 21 | JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }} 22 | JIRA_PROJECT: ${{ secrets.JIRA_PROJECT }} 23 | -------------------------------------------------------------------------------- /test/md5.js: -------------------------------------------------------------------------------- 1 | import md5 from 'blueimp-md5'; 2 | 3 | describe('MD5', function () { 4 | var encodeCases = [ 5 | ['', 'd41d8cd98f00b204e9800998ecf8427e'], 6 | ['foobar', '3858f62230ac3c915f300c664312c63f'], 7 | ]; 8 | 9 | it('should hash properly', function () { 10 | for (var i = 0; i < encodeCases.length; i++) { 11 | assert.equal(encodeCases[i][1], md5(encodeCases[i][0])); 12 | } 13 | }); 14 | 15 | it('should hash unicode properly', function () { 16 | assert.equal('db36e9b42b9fa2863f94280206fb4d74', md5('\u2661')); 17 | }); 18 | 19 | it('should hash multi-byte unicode properly', function () { 20 | assert.equal('8fb34591f1a56cf3ca9837774f4b7bd7', md5('\uD83D\uDE1C')); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/worker-storage.js: -------------------------------------------------------------------------------- 1 | export default class WorkerStorage { 2 | constructor() { 3 | this.map = new Map(); 4 | this.length = 0; 5 | } 6 | 7 | key(index) { 8 | const keys = Array.from(this.map.keys()); 9 | const key = keys[index]; 10 | return this.map.get(key); 11 | } 12 | 13 | getItem(key) { 14 | return this.map.get(key); 15 | } 16 | 17 | setItem(key, value) { 18 | if (!this.map.has(key)) { 19 | this.length += 1; 20 | } 21 | this.map.set(key, value); 22 | } 23 | 24 | removeItem(key) { 25 | if (this.map.has(key)) { 26 | this.length -= 1; 27 | this.map.delete(key); 28 | } 29 | } 30 | 31 | clear() { 32 | this.map.clear(); 33 | this.length = 0; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /website/src/pages/styles.module.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | 3 | /** 4 | * CSS files with the .module.css suffix will be treated as CSS modules 5 | * and scoped locally. 6 | */ 7 | 8 | .heroBanner { 9 | padding: 4rem 0; 10 | text-align: center; 11 | position: relative; 12 | overflow: hidden; 13 | } 14 | 15 | @media screen and (max-width: 966px) { 16 | .heroBanner { 17 | padding: 2rem; 18 | } 19 | } 20 | 21 | .buttons { 22 | display: flex; 23 | align-items: center; 24 | justify-content: center; 25 | } 26 | 27 | .features { 28 | display: flex; 29 | align-items: center; 30 | padding: 2rem 0; 31 | width: 100%; 32 | } 33 | 34 | .featureImage { 35 | height: 200px; 36 | width: 200px; 37 | } 38 | -------------------------------------------------------------------------------- /test/browser/snippet.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Amplitude Snippet Tests 6 | 7 | 8 | 9 |
10 | 11 | 12 | 13 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /test/base64Id.js: -------------------------------------------------------------------------------- 1 | import base64Id from '../src/base64Id'; 2 | 3 | describe('base64Id', () => { 4 | it('should return an id with length 22', () => { 5 | assert.equal(base64Id().length, 22); 6 | }); 7 | 8 | // If this test fails randomly it'll be frustrating to reproduce. Ideally 9 | // there would be some reproducible seed we would print for every test run. 10 | it('should return an id of safe base64 characters', () => { 11 | assert.equal(true, /^[a-zA-Z0-9\-_]*$/.test(base64Id())); 12 | }); 13 | 14 | it('should generate a unique base64Id', () => { 15 | const ids = new Set(); 16 | const count = 10000; 17 | for (let i = 0; i < count; i++) { 18 | ids.add(base64Id()); 19 | } 20 | assert.equal(ids.size, count); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /test/config-manager.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import ConfigManager from '../src/config-manager'; 3 | import { AmplitudeServerZone } from '../src/server-zone'; 4 | import Constants from '../src/constants'; 5 | 6 | describe('ConfigManager', function () { 7 | let server; 8 | beforeEach(function () { 9 | server = sinon.fakeServer.create(); 10 | }); 11 | 12 | afterEach(function () { 13 | server.restore(); 14 | }); 15 | 16 | it('ConfigManager should support EU zone', function () { 17 | ConfigManager.refresh(AmplitudeServerZone.EU, true, function () { 18 | assert.equal(Constants.EVENT_LOG_EU_URL, ConfigManager.ingestionEndpoint); 19 | }); 20 | server.respondWith('{"ingestionEndpoint": "api.eu.amplitude.com"}'); 21 | server.respond(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/Bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 🐛 3 | about: You're having technical issues 4 | labels: 'bug' 5 | --- 6 | 7 | 8 | 9 | ## Expected Behavior 10 | 11 | 12 | ## Current Behavior 13 | 14 | 15 | ## Possible Solution 16 | 17 | 18 | ## Steps to Reproduce 19 | 20 | 21 | 1. 22 | 2. 23 | 3. 24 | 4. 25 | 26 | ## Environment 27 | - JS SDK Version: 28 | - Installation Method: 29 | - Browser and Version: 30 | -------------------------------------------------------------------------------- /scripts/readme.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs'); 2 | var path = require('path'); 3 | 4 | // Update the README with the minified snippet. 5 | var cwd = process.cwd(); 6 | var readmeFilename = path.join(cwd, 'README.md'); 7 | var readme = fs.readFileSync(readmeFilename, 'utf-8'); 8 | 9 | var snippetFilename = path.join(cwd, 'amplitude-snippet.min.js'); 10 | var snippet = fs.readFileSync(snippetFilename, 'utf-8'); 11 | var script = 12 | ' '; 17 | 18 | var updated = readme.replace(/ +/, script); 19 | fs.writeFileSync(readmeFilename, updated); 20 | 21 | console.log('Updated README.md'); 22 | -------------------------------------------------------------------------------- /test/top-domain.js: -------------------------------------------------------------------------------- 1 | import topDomain from '../src/top-domain.js'; 2 | import { mockCookie, restoreCookie } from './mock-cookie'; 3 | 4 | describe('topDomain', () => { 5 | it('should return an empty string for localhost', () => { 6 | assert.equal(topDomain('http://localhost:9000'), ''); 7 | }); 8 | 9 | it('should return an empty string for an ip address', () => { 10 | assert.equal(topDomain('http://192.168.2.4:9000'), ''); 11 | }); 12 | 13 | it('should return an empty string for a domain it cannot write to', () => { 14 | assert.equal(topDomain('https://www.example.com'), ''); 15 | }); 16 | 17 | it('should return the smallest domain it can write to', () => { 18 | mockCookie(); 19 | assert.equal(topDomain('https://foo.www.example.com'), 'example.com'); 20 | restoreCookie(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /rollup.esm.js: -------------------------------------------------------------------------------- 1 | import commonjs from '@rollup/plugin-commonjs'; 2 | import replace from '@rollup/plugin-replace'; 3 | import babel from '@rollup/plugin-babel'; 4 | import json from '@rollup/plugin-json'; 5 | 6 | export default { 7 | input: 'src/index.js', 8 | output: { 9 | name: 'amplitude', 10 | file: 'amplitude.esm.js', 11 | format: 'esm', 12 | }, 13 | plugins: [ 14 | json(), 15 | replace({ 16 | preventAssignment: true, 17 | BUILD_COMPAT_SNIPPET: 'false', 18 | BUILD_COMPAT_LOCAL_STORAGE: 'true', 19 | BUILD_COMPAT_2_0: 'true', 20 | }), 21 | commonjs({ 22 | include: "node_modules/**" 23 | }), 24 | babel({ 25 | babelHelpers: 'bundled', 26 | exclude: 'node_modules/**', 27 | plugins: [ 28 | '@babel/plugin-proposal-object-rest-spread' 29 | ], 30 | }), 31 | ], 32 | }; 33 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["eslint:recommended", "plugin:prettier/recommended"], 3 | "plugins": ["prettier", "mocha", "@amplitude/eslint-plugin-amplitude"], 4 | "env": { "es6": true, "browser": true, "node": true, "mocha": true }, 5 | "parserOptions": { 6 | "sourceType": "module", 7 | "ecmaVersion": 2018 8 | }, 9 | "rules": { 10 | "prettier/prettier": "error", 11 | "mocha/no-skipped-tests": "error", 12 | "mocha/no-exclusive-tests": "error" 13 | }, 14 | "globals": { 15 | "BUILD_COMPAT_LOCAL_STORAGE": "readonly", 16 | "BUILD_COMPAT_SNIPPET": "readonly", 17 | "BUILD_COMPAT_2_0": "readonly", 18 | "assert": "readonly", 19 | "expect": "readonly", 20 | "should": "readonly", 21 | "define": "readonly", 22 | "amplitude": "readonly", 23 | "opera": "readonly", 24 | "ActiveXObject": "readonly" 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/browser/server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const https = require('https'); 3 | const fs = require('fs'); 4 | 5 | const port = 9000; 6 | const httpsPort = 9001; 7 | 8 | const app = express(); 9 | app.post('/test2.html', (req, res) => { 10 | res.set('Content-Type', 'text/html'); 11 | res.send(fs.readFileSync(__dirname + '/test2.html')); 12 | }); 13 | app.use(express.static(__dirname + '/../..')); 14 | app.use(express.static(__dirname)); 15 | 16 | app.listen(port); 17 | console.log(`Listening on http://localhost:${port}`); 18 | 19 | if (process.env.USE_SSL) { 20 | const options = { 21 | key: fs.readFileSync(`${__dirname}/wildcard.amplidev.com.key`), 22 | cert: fs.readFileSync(`${__dirname}/wildcard.amplidev.com.crt`), 23 | }; 24 | https.createServer(options, app).listen(httpsPort); 25 | console.log(`Listening with https on port ${httpsPort}...`); 26 | } 27 | -------------------------------------------------------------------------------- /karma-web-worker.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = config => { 2 | config.set({ 3 | frameworks: ['mocha-webworker'], 4 | files: [ 5 | { 6 | pattern: 'test/web-worker.js', 7 | included: false, 8 | }, 9 | { 10 | pattern: 'amplitude.js', 11 | included: false, 12 | }, 13 | { 14 | pattern: 'node_modules/sinon/pkg/sinon.js', 15 | included: false, 16 | }, 17 | ], 18 | browsers: ['ChromeHeadless'], 19 | autoWatch: false, 20 | singleRun: true, 21 | reporters: ['mocha'], 22 | client: { 23 | mochaWebWorker: { 24 | pattern: [ 25 | 'test/web-worker.js', 26 | 'amplitude.js', 27 | 'node_modules/sinon/pkg/sinon.js' 28 | ], 29 | worker: 'Worker', 30 | mocha: { 31 | ui: 'bdd' 32 | } 33 | } 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /src/top-domain.js: -------------------------------------------------------------------------------- 1 | import baseCookie from './base-cookie'; 2 | import base64Id from './base64Id'; 3 | import utils from './utils'; 4 | 5 | // Utility that finds top level domain to write to 6 | const topDomain = (url) => { 7 | const host = utils.getHost(url); 8 | const parts = host.split('.'); 9 | const levels = []; 10 | const cname = '_tldtest_' + base64Id(); 11 | 12 | if (utils.isWebWorkerEnvironment()) return ''; 13 | 14 | for (let i = parts.length - 2; i >= 0; --i) { 15 | levels.push(parts.slice(i).join('.')); 16 | } 17 | 18 | for (let i = 0; i < levels.length; ++i) { 19 | const domain = levels[i]; 20 | const opts = { domain: '.' + domain }; 21 | 22 | baseCookie.set(cname, 1, opts); 23 | if (baseCookie.get(cname)) { 24 | baseCookie.set(cname, null, opts); 25 | return domain; 26 | } 27 | } 28 | 29 | return ''; 30 | }; 31 | 32 | export default topDomain; 33 | -------------------------------------------------------------------------------- /test/server-zone.js: -------------------------------------------------------------------------------- 1 | import { AmplitudeServerZone, getEventLogApi, getDynamicConfigApi } from '../src/server-zone'; 2 | import Constants from '../src/constants'; 3 | 4 | describe('AmplitudeServerZone', function () { 5 | it('getEventLogApi should return correct event log url', function () { 6 | assert.equal(Constants.EVENT_LOG_URL, getEventLogApi(AmplitudeServerZone.US)); 7 | assert.equal(Constants.EVENT_LOG_EU_URL, getEventLogApi(AmplitudeServerZone.EU)); 8 | assert.equal(Constants.EVENT_LOG_URL, getEventLogApi('')); 9 | }); 10 | 11 | it('getDynamicConfigApi should return correct dynamic config url', function () { 12 | assert.equal(Constants.DYNAMIC_CONFIG_URL, getDynamicConfigApi(AmplitudeServerZone.US)); 13 | assert.equal(Constants.DYNAMIC_CONFIG_EU_URL, getDynamicConfigApi(AmplitudeServerZone.EU)); 14 | assert.equal(Constants.DYNAMIC_CONFIG_URL, getDynamicConfigApi('')); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /src/uuid.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Source: [jed's gist's comment]{@link https://gist.github.com/jed/982883?permalink_comment_id=3223002#gistcomment-3223002}. 3 | * Returns a random v4 UUID of the form xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx, 4 | * where each x is replaced with a random hexadecimal digit from 0 to f, and 5 | * y is replaced with a random hexadecimal digit from 8 to b. 6 | * Used to generate UUIDs for deviceIds. 7 | * @private 8 | */ 9 | 10 | // hoist hex table out of the function to avoid re-calculation 11 | const hex = [...Array(256).keys()].map((index) => index.toString(16).padStart(2, '0')); 12 | 13 | var uuid = () => { 14 | const r = crypto.getRandomValues(new Uint8Array(16)); 15 | 16 | r[6] = (r[6] & 0x0f) | 0x40; 17 | r[8] = (r[8] & 0x3f) | 0x80; 18 | 19 | return [...r.entries()].map(([index, int]) => ([4, 6, 8, 10].includes(index) ? `-${hex[int]}` : hex[int])).join(''); 20 | }; 21 | 22 | export default uuid; 23 | -------------------------------------------------------------------------------- /website/src/css/custom.css: -------------------------------------------------------------------------------- 1 | /* stylelint-disable docusaurus/copyright-header */ 2 | /** 3 | * Any CSS included here will be global. The classic template 4 | * bundles Infima by default. Infima is a CSS framework designed to 5 | * work well for content-centric websites. 6 | */ 7 | 8 | /* You can override the default Infima variables here. */ 9 | :root { 10 | --ifm-color-primary: #007fd2; 11 | --ifm-color-primary-dark: #005488; 12 | --ifm-color-primary-darker: #003b61; 13 | --ifm-color-primary-darkest: #0c2a4b; 14 | --ifm-color-primary-light: #48a4de; 15 | --ifm-color-primary-lighter: #7bbee7; 16 | --ifm-color-primary-lightest: #c6e2f4; 17 | --ifm-color-danger: #E71829; 18 | --ifm-code-font-size: 95%; 19 | } 20 | 21 | .docusaurus-highlight-code-line { 22 | background-color: rgb(72, 77, 91); 23 | display: block; 24 | margin: 0 calc(-1 * var(--ifm-pre-padding)); 25 | padding: 0 var(--ifm-pre-padding); 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | lint-check: 7 | name: Check for ESLint + Prettier Violations 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - name: Check out Git repository 12 | uses: actions/checkout@v2 13 | 14 | - name: node_modules cache 15 | uses: actions/cache@v2 16 | with: 17 | path: '**/node_modules' 18 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 19 | 20 | - name: Use Node.js 21 | uses: actions/setup-node@v3 22 | with: 23 | node-version: 16.x 24 | 25 | - name: Install dependencies 26 | run: | 27 | yarn install --frozen-lockfile --network-timeout 300000 28 | 29 | - name: Prettier check 30 | run: | 31 | yarn run lint:prettier 32 | 33 | - name: Eslint check 34 | run: | 35 | yarn run lint:eslint 36 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | // Entry point 2 | import Amplitude from './amplitude'; 3 | import GlobalScope from './global-scope'; 4 | 5 | const old = (typeof GlobalScope !== 'undefined' && GlobalScope.amplitude) || {}; 6 | const newInstance = new Amplitude(); 7 | newInstance._q = old._q || []; 8 | 9 | /** 10 | * Instantiates Amplitude object and runs all queued function logged by stubbed methods provided by snippets 11 | * Event queue allows async loading of SDK to not blocking client's app 12 | */ 13 | for (let instance in old._iq) { 14 | // migrate each instance's queue 15 | if (Object.prototype.hasOwnProperty.call(old._iq, instance)) { 16 | newInstance.getInstance(instance)._q = old._iq[instance]._q || []; 17 | } 18 | } 19 | 20 | // If SDK is enabled as snippet, process the events queued by stubbed function 21 | if (BUILD_COMPAT_SNIPPET) { 22 | newInstance.runQueuedFunctions(); 23 | } 24 | 25 | // export the instance 26 | export default newInstance; 27 | -------------------------------------------------------------------------------- /website/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "docs", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "docusaurus": "docusaurus", 7 | "generate-jsdoc": "node generate-jsdoc", 8 | "start": "docusaurus start", 9 | "build": "docusaurus build", 10 | "swizzle": "docusaurus swizzle", 11 | "deploy": "docusaurus deploy", 12 | "serve": "docusaurus build --out-dir build/Amplitude-JavaScript && yarn run docusaurus serve" 13 | }, 14 | "dependencies": { 15 | "@docusaurus/core": "^2.3.1", 16 | "@docusaurus/preset-classic": "^2.3.1", 17 | "clsx": "^1.2.1", 18 | "react": "^18.2.0", 19 | "react-dom": "^18.2.0" 20 | }, 21 | "browserslist": { 22 | "production": [ 23 | ">0.1%", 24 | "not dead", 25 | "not op_mini all" 26 | ], 27 | "development": [ 28 | "last 2 chrome version", 29 | "last 2 firefox version", 30 | "last 2 safari version" 31 | ] 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import json from '@rollup/plugin-json'; 4 | import resolve from '@rollup/plugin-node-resolve'; 5 | import replace from '@rollup/plugin-replace'; 6 | 7 | export default { 8 | input: 'src/index.js', 9 | output: { 10 | name: 'amplitude', 11 | file: 'amplitude.js', 12 | format: 'iife', 13 | amd: { 14 | id: 'amplitude', 15 | } 16 | }, 17 | plugins: [ 18 | json(), 19 | resolve({ 20 | browser: true, 21 | }), 22 | replace({ 23 | preventAssignment: true, 24 | BUILD_COMPAT_SNIPPET: 'true', 25 | BUILD_COMPAT_LOCAL_STORAGE: 'true', 26 | BUILD_COMPAT_2_0: 'true', 27 | }), 28 | commonjs(), 29 | babel({ 30 | babelHelpers: 'bundled', 31 | exclude: 'node_modules/**', 32 | plugins: [ 33 | '@babel/plugin-proposal-object-rest-spread' 34 | ], 35 | }), 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /rollup.umd.js: -------------------------------------------------------------------------------- 1 | import resolve from '@rollup/plugin-node-resolve'; 2 | import commonjs from '@rollup/plugin-commonjs'; 3 | import replace from '@rollup/plugin-replace'; 4 | import babel from '@rollup/plugin-babel'; 5 | import json from '@rollup/plugin-json'; 6 | 7 | export default { 8 | input: 'src/index.js', 9 | output: { 10 | name: 'amplitude', 11 | file: 'amplitude.umd.js', 12 | format: 'umd', 13 | amd: { 14 | id: 'amplitude', 15 | } 16 | }, 17 | plugins: [ 18 | json(), 19 | resolve({ 20 | browser: true, 21 | }), 22 | replace({ 23 | preventAssignment: true, 24 | BUILD_COMPAT_SNIPPET: 'true', 25 | BUILD_COMPAT_LOCAL_STORAGE: 'true', 26 | BUILD_COMPAT_2_0: 'true', 27 | }), 28 | commonjs(), 29 | babel({ 30 | babelHelpers: 'bundled', 31 | exclude: 'node_modules/**', 32 | plugins: [ 33 | '@babel/plugin-proposal-object-rest-spread' 34 | ], 35 | }), 36 | ], 37 | }; 38 | -------------------------------------------------------------------------------- /scripts/release.js: -------------------------------------------------------------------------------- 1 | var fs = require('fs-extra'); 2 | var path = require('path'); 3 | const { version } = require('../package'); 4 | var exec = require('child_process').exec; 5 | 6 | var cwd = process.cwd(); 7 | 8 | var file = path.join(cwd, 'dist', 'amplitude-' + version + '.js'); 9 | var minfile = path.join(cwd, 'dist', 'amplitude-' + version + '-min.js'); 10 | var mingzfile = path.join(cwd, 'dist', 'amplitude-' + version + '-min.gz.js'); 11 | 12 | fs.copySync(path.join(cwd, 'amplitude.js'), file); 13 | fs.copySync(path.join(cwd, 'amplitude.min.js'), minfile); 14 | exec('gzip < ' + minfile + ' > ' + mingzfile); 15 | 16 | const umdFile = path.join(cwd, 'dist', 'amplitude-' + version + '.umd.js'); 17 | const umdMinfile = path.join(cwd, 'dist', 'amplitude-' + version + '-min.umd.js'); 18 | const umdMingzfile = path.join(cwd, 'dist', 'amplitude-' + version + '-min.umd.gz.js'); 19 | 20 | fs.copySync(path.join(cwd, 'amplitude.umd.js'), umdFile); 21 | fs.copySync(path.join(cwd, 'amplitude.min.js'), umdMinfile); 22 | exec('gzip < ' + umdMinfile + ' > ' + umdMingzfile); 23 | -------------------------------------------------------------------------------- /test/browser/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Amplitude Tests 6 | 22 | 23 | 24 |
25 |
26 | mocha tests 27 | Run the unit tests in your browser 28 |
29 |
30 | snippet tests 31 | Run the snippet unit tests in your browser 32 |
33 | require js 34 | segment 35 | amplitude js 36 |
37 | 38 | 39 | -------------------------------------------------------------------------------- /test/base64.js: -------------------------------------------------------------------------------- 1 | import Base64 from '../src/base64.js'; 2 | 3 | describe('Base64', function () { 4 | var encodeCases = [ 5 | ['', ''], 6 | ['f', 'Zg=='], 7 | ['fo', 'Zm8='], 8 | ['foo', 'Zm9v'], 9 | ['foob', 'Zm9vYg=='], 10 | ['fooba', 'Zm9vYmE='], 11 | ['foobar', 'Zm9vYmFy'], 12 | ['\u2661', '4pmh'], 13 | ]; 14 | 15 | var decodeCases = [ 16 | ['', ''], 17 | ['Zg', 'f'], 18 | ['Zg==', 'f'], 19 | ['Zm8', 'fo'], 20 | ['Zm8=', 'fo'], 21 | ['Zm9v', 'foo'], 22 | ['Zm9vYg', 'foob'], 23 | ['Zm9vYg==', 'foob'], 24 | ['Zm9vYmE', 'fooba'], 25 | ['Zm9vYmE=', 'fooba'], 26 | ['Zm9vYmFy', 'foobar'], 27 | ['4pmh', '\u2661'], 28 | ]; 29 | 30 | it('should encode properly', function () { 31 | for (var i = 0; i < encodeCases.length; i++) { 32 | assert.equal(encodeCases[i][1], Base64.encode(encodeCases[i][0])); 33 | } 34 | }); 35 | 36 | it('should decode properly', function () { 37 | for (var i = 0; i < decodeCases.length; i++) { 38 | assert.equal(decodeCases[i][1], Base64.decode(decodeCases[i][0])); 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /release.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "branches": ["main"], 3 | "plugins": [ 4 | ["@semantic-release/commit-analyzer", { 5 | "preset": "angular", 6 | "parserOpts": { 7 | "noteKeywords": ["BREAKING CHANGE", "BREAKING CHANGES", "BREAKING"] 8 | } 9 | }], 10 | ["@semantic-release/release-notes-generator", { 11 | "preset": "angular", 12 | }], 13 | ["@semantic-release/changelog", { 14 | "changelogFile": "CHANGELOG.md" 15 | }], 16 | "@semantic-release/npm", 17 | ["@semantic-release/exec", { 18 | "prepareCmd": "make release && node scripts/create-snippet-instructions.js", 19 | "publishCmd": "python scripts/deploy_s3.py --version ${nextRelease.version}", 20 | "failCmd": "npm unpublish amplitude-js@${nextRelease.version}" 21 | }], 22 | ["@semantic-release/github", { 23 | "assets": "amplitude*.js" 24 | }], 25 | ["@semantic-release/git", { 26 | "assets": ["package.json", "src/amplitude-snippet.js", "CHANGELOG.md"], 27 | "message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 28 | }], 29 | ], 30 | } 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2014 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 | -------------------------------------------------------------------------------- /test/mock-cookie.js: -------------------------------------------------------------------------------- 1 | let rawCookieData = {}; 2 | 3 | let isMocked = false; 4 | 5 | export const mockCookie = (options) => { 6 | const { disabled } = options || {}; 7 | isMocked = true; 8 | 9 | document.__defineGetter__('cookie', function () { 10 | return Object.keys(rawCookieData) 11 | .map((key) => `${key}=${rawCookieData[key].val}`) 12 | .join(';'); 13 | }); 14 | 15 | document.__defineSetter__('cookie', function (str) { 16 | if (disabled) { 17 | return ''; 18 | } 19 | const indexEquals = str.indexOf('='); 20 | const key = str.substr(0, indexEquals); 21 | const remainingStr = str.substring(key.length + 1); 22 | const splitSemi = remainingStr.split(';').map((str) => str.trim()); 23 | 24 | rawCookieData[key] = { 25 | val: splitSemi[0], 26 | options: splitSemi.slice(1), 27 | }; 28 | return str; 29 | }); 30 | }; 31 | 32 | export const restoreCookie = () => { 33 | if (isMocked) { 34 | delete document['cookie']; 35 | rawCookieData = {}; 36 | isMocked = false; 37 | } 38 | }; 39 | 40 | export const getCookie = (key) => { 41 | return rawCookieData[key]; 42 | }; 43 | -------------------------------------------------------------------------------- /src/type.js: -------------------------------------------------------------------------------- 1 | /** 2 | * toString ref. 3 | * @private 4 | */ 5 | 6 | var toString = Object.prototype.toString; 7 | 8 | /** 9 | * Return the type of `val`. 10 | * @private 11 | * @param {Mixed} val 12 | * @return {String} 13 | * @api public 14 | */ 15 | 16 | export default function (val) { 17 | switch (toString.call(val)) { 18 | case '[object Date]': 19 | return 'date'; 20 | case '[object RegExp]': 21 | return 'regexp'; 22 | case '[object Arguments]': 23 | return 'arguments'; 24 | case '[object Array]': 25 | return 'array'; 26 | case '[object Error]': 27 | return 'error'; 28 | } 29 | 30 | if (val === null) { 31 | return 'null'; 32 | } 33 | if (val === undefined) { 34 | return 'undefined'; 35 | } 36 | if (val !== val) { 37 | return 'nan'; 38 | } 39 | if (val && val.nodeType === 1) { 40 | return 'element'; 41 | } 42 | 43 | if (typeof Buffer !== 'undefined' && typeof Buffer.isBuffer === 'function' && Buffer.isBuffer(val)) { 44 | return 'buffer'; 45 | } 46 | 47 | val = val.valueOf ? val.valueOf() : Object.prototype.valueOf.apply(val); 48 | return typeof val; 49 | } 50 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | # https://github.community/t/how-to-trigger-an-action-on-push-or-pull-request-but-not-both/16662 4 | on: 5 | push: 6 | branches: 7 | - main 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | test: 14 | name: Execute full unit test suite 15 | strategy: 16 | fail-fast: false 17 | matrix: 18 | node-version: [14.x, 16.x, 18.x] 19 | os: [macOS-latest, ubuntu-latest] 20 | runs-on: ${{ matrix.os }} 21 | 22 | steps: 23 | - name: Check out Git repository 24 | uses: actions/checkout@v2 25 | 26 | - name: node_modules cache 27 | uses: actions/cache@v2 28 | with: 29 | path: '**/node_modules' 30 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: ${{ matrix.node-version }} 36 | 37 | - name: Install dependencies 38 | run: | 39 | yarn install --frozen-lockfile --network-timeout 300000 40 | 41 | - name: Build and run tests 42 | run: | 43 | make test 44 | -------------------------------------------------------------------------------- /test/cookie.js: -------------------------------------------------------------------------------- 1 | import cookie from '../src/cookie.js'; 2 | 3 | describe('Cookie', () => { 4 | before(() => { 5 | cookie.reset(); 6 | }); 7 | 8 | afterEach(() => { 9 | cookie.remove('x'); 10 | cookie.reset(); 11 | }); 12 | 13 | describe('get', () => { 14 | it('should get an existing cookie', () => { 15 | cookie.set('x', { a: 'b' }); 16 | assert.deepEqual(cookie.get('x'), { a: 'b' }); 17 | }); 18 | 19 | it('should not throw an error on a malformed cookie', () => { 20 | document.cookie = 'x=y; path=/'; 21 | assert.isNull(cookie.get('x')); 22 | }); 23 | }); 24 | 25 | describe('remove', () => { 26 | it('should remove a cookie', () => { 27 | cookie.set('x', { a: 'b' }); 28 | assert.deepEqual(cookie.get('x'), { a: 'b' }); 29 | cookie.remove('x'); 30 | assert.isNull(cookie.get('x')); 31 | }); 32 | }); 33 | 34 | describe('options', () => { 35 | it('should set default options', () => { 36 | assert.deepEqual(cookie.options(), { 37 | expirationDays: undefined, 38 | domain: undefined, 39 | }); 40 | }); 41 | 42 | it('should save options', () => { 43 | cookie.options({ expirationDays: 1 }); 44 | assert.equal(cookie.options().expirationDays, 1); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/server-zone.js: -------------------------------------------------------------------------------- 1 | import Constants from './constants'; 2 | 3 | /** 4 | * AmplitudeServerZone is for Data Residency and handling server zone related properties. 5 | * The server zones now are US and EU. 6 | * 7 | * For usage like sending data to Amplitude's EU servers, you need to configure the serverZone during nitializing. 8 | */ 9 | const AmplitudeServerZone = { 10 | US: 'US', 11 | EU: 'EU', 12 | }; 13 | 14 | const getEventLogApi = (serverZone) => { 15 | let eventLogUrl = Constants.EVENT_LOG_URL; 16 | switch (serverZone) { 17 | case AmplitudeServerZone.EU: 18 | eventLogUrl = Constants.EVENT_LOG_EU_URL; 19 | break; 20 | case AmplitudeServerZone.US: 21 | eventLogUrl = Constants.EVENT_LOG_URL; 22 | break; 23 | default: 24 | break; 25 | } 26 | return eventLogUrl; 27 | }; 28 | 29 | const getDynamicConfigApi = (serverZone) => { 30 | let dynamicConfigUrl = Constants.DYNAMIC_CONFIG_URL; 31 | switch (serverZone) { 32 | case AmplitudeServerZone.EU: 33 | dynamicConfigUrl = Constants.DYNAMIC_CONFIG_EU_URL; 34 | break; 35 | case AmplitudeServerZone.US: 36 | dynamicConfigUrl = Constants.DYNAMIC_CONFIG_URL; 37 | break; 38 | default: 39 | break; 40 | } 41 | return dynamicConfigUrl; 42 | }; 43 | 44 | export { AmplitudeServerZone, getEventLogApi, getDynamicConfigApi }; 45 | -------------------------------------------------------------------------------- /scripts/version.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const { version } = require('../package'); 4 | const crypto = require('crypto'); 5 | 6 | const cwd = process.cwd(); 7 | 8 | function replaceTextInFile(filepath, match, replacement) { 9 | var filename = path.join(cwd, filepath); 10 | 11 | const updatedText = fs.readFileSync(filename, 'utf-8').replace(match, replacement); 12 | 13 | if (updatedText.indexOf(replacement) === -1) { 14 | throw new Error(`Failed to update text in ${filepath}`); 15 | } 16 | 17 | fs.writeFileSync(filename, updatedText); 18 | 19 | console.log(`Updated ${filepath}: ${replacement}`); 20 | } 21 | 22 | // Update version in snippet 23 | replaceTextInFile( 24 | path.join('src', 'amplitude-snippet.js'), 25 | /cdn\.amplitude\.com\/libs\/amplitude-[0-9]+\.[0-9]+\.[0-9]+-min\.gz\.js/, 26 | `cdn.amplitude.com/libs/amplitude-${version}-min.gz.js`, 27 | ); 28 | 29 | // Update integrity hash in snippet 30 | // Provides extra layer of security. If script changes, it will fail to load 31 | const sdkText = fs.readFileSync(path.join('.', `amplitude.min.js`), 'utf-8'); 32 | const hash = crypto.createHash('sha384').update(sdkText).digest('base64'); 33 | replaceTextInFile( 34 | path.join('src', 'amplitude-snippet.js'), 35 | /as.integrity = 'sha384-[a-zA-Z0-9+/]+';/, 36 | `as.integrity = 'sha384-${hash}';`, 37 | ); 38 | 39 | console.log(`Updated version to ${version}`); 40 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | # Website 2 | 3 | The JavaScript SDK API Reference website is built using [Docusaurus 2](https://v2.docusaurus.io/), a modern static website generator. 4 | 5 | ### Installation 6 | 7 | Run `yarn docs:install` to install website dependencies. 8 | 9 | ### Generating `website/docs/` from `src/` 10 | 11 | The website autogenerates markdown files of public classes and its contents using [generate-jsdoc.js](https://github.com/amplitude/Amplitude-JavaScript/blob/main/website/generate-jsdoc.js). 12 | 13 | This is done by calling `yarn docs:generate-jsdoc` from the base directory. 14 | 15 | ### Local Development Build 16 | 17 | Run `yarn start` from this directory or `yarn docs:start` from the base directory. 18 | 19 | Because of a bug with how Docusaurus handles `baseUrl` in `docusaurus.config.js`, you should open `localhost:3000/Amplitude-JavaScript` instead of the default `localhost:3000/` 20 | 21 | ### Local Production Build 22 | 23 | Similar to local development build process. This command generates static content into the `website/build/` directory and creates a server to serve it. 24 | 25 | Run `yarn serve` from this directory or `yarn docs:serve` from the base directory. Then open `localhost:3000/Amplitude-JavaScript` 26 | 27 | ### Deployment 28 | 29 | ``` 30 | $ GIT_USER= USE_SSH=true yarn deploy 31 | ``` 32 | 33 | This will create the production build and push it the `gh-pages` branch. 34 | -------------------------------------------------------------------------------- /src/utm.js: -------------------------------------------------------------------------------- 1 | import utils from './utils'; 2 | import Constants from './constants'; 3 | 4 | var getUtmData = function getUtmData(rawCookie, query) { 5 | // Translate the utmz cookie format into url query string format. 6 | var cookie = rawCookie ? '?' + rawCookie.split('.').slice(-1)[0].replace(/\|/g, '&') : ''; 7 | 8 | var fetchParam = function fetchParam(queryName, query, cookieName, cookie) { 9 | return utils.getQueryParam(queryName, query) || utils.getQueryParam(cookieName, cookie); 10 | }; 11 | 12 | var utmSource = fetchParam(Constants.UTM_SOURCE, query, 'utmcsr', cookie); 13 | var utmMedium = fetchParam(Constants.UTM_MEDIUM, query, 'utmcmd', cookie); 14 | var utmCampaign = fetchParam(Constants.UTM_CAMPAIGN, query, 'utmccn', cookie); 15 | var utmTerm = fetchParam(Constants.UTM_TERM, query, 'utmctr', cookie); 16 | var utmContent = fetchParam(Constants.UTM_CONTENT, query, 'utmcct', cookie); 17 | 18 | var utmData = {}; 19 | var addIfNotNull = function addIfNotNull(key, value) { 20 | if (!utils.isEmptyString(value)) { 21 | utmData[key] = value; 22 | } 23 | }; 24 | 25 | addIfNotNull(Constants.UTM_SOURCE, utmSource); 26 | addIfNotNull(Constants.UTM_MEDIUM, utmMedium); 27 | addIfNotNull(Constants.UTM_CAMPAIGN, utmCampaign); 28 | addIfNotNull(Constants.UTM_TERM, utmTerm); 29 | addIfNotNull(Constants.UTM_CONTENT, utmContent); 30 | 31 | return utmData; 32 | }; 33 | 34 | export default getUtmData; 35 | -------------------------------------------------------------------------------- /src/utf8.js: -------------------------------------------------------------------------------- 1 | /* 2 | * UTF-8 encoder/decoder 3 | * http://www.webtoolkit.info/ 4 | */ 5 | var UTF8 = { 6 | encode: function (s) { 7 | var utftext = ''; 8 | 9 | for (var n = 0; n < s.length; n++) { 10 | var c = s.charCodeAt(n); 11 | 12 | if (c < 128) { 13 | utftext += String.fromCharCode(c); 14 | } else if (c > 127 && c < 2048) { 15 | utftext += String.fromCharCode((c >> 6) | 192); 16 | utftext += String.fromCharCode((c & 63) | 128); 17 | } else { 18 | utftext += String.fromCharCode((c >> 12) | 224); 19 | utftext += String.fromCharCode(((c >> 6) & 63) | 128); 20 | utftext += String.fromCharCode((c & 63) | 128); 21 | } 22 | } 23 | return utftext; 24 | }, 25 | 26 | decode: function (utftext) { 27 | var s = ''; 28 | var i = 0; 29 | var c = 0, 30 | c1 = 0, 31 | c2 = 0; 32 | 33 | while (i < utftext.length) { 34 | c = utftext.charCodeAt(i); 35 | if (c < 128) { 36 | s += String.fromCharCode(c); 37 | i++; 38 | } else if (c > 191 && c < 224) { 39 | c1 = utftext.charCodeAt(i + 1); 40 | s += String.fromCharCode(((c & 31) << 6) | (c1 & 63)); 41 | i += 2; 42 | } else { 43 | c1 = utftext.charCodeAt(i + 1); 44 | c2 = utftext.charCodeAt(i + 2); 45 | s += String.fromCharCode(((c & 15) << 12) | ((c1 & 63) << 6) | (c2 & 63)); 46 | i += 3; 47 | } 48 | } 49 | return s; 50 | }, 51 | }; 52 | 53 | export default UTF8; 54 | -------------------------------------------------------------------------------- /website/docusaurus.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | title: 'Amplitude JS SDK Docs', 3 | tagline: 'Amplitude JavaScript SDK', 4 | url: 'https://amplitude.github.io', 5 | baseUrl: '/Amplitude-JavaScript/', 6 | onBrokenLinks: 'throw', 7 | favicon: 'img/amp_favicon.ico', 8 | organizationName: 'Amplitude', 9 | projectName: 'Amplitude-JavaScript', 10 | themeConfig: { 11 | navbar: { 12 | logo: { 13 | alt: 'Amplitude Logo', 14 | src: 'img/amp_logo.svg', 15 | }, 16 | hideOnScroll: true, 17 | items: [ 18 | { 19 | href: 'https://github.com/amplitude/Amplitude-JavaScript/', 20 | label: 'GitHub', 21 | position: 'left', 22 | }, 23 | ], 24 | }, 25 | footer: { 26 | logo: { 27 | alt: 'Amplitude Logo', 28 | src: 'img/amp_logo.svg', 29 | }, 30 | copyright: `Copyright © ${new Date().getFullYear()} Amplitude, Inc.`, 31 | }, 32 | prism: { 33 | defaultLanguage: 'javascript', 34 | }, 35 | }, 36 | presets: [ 37 | [ 38 | '@docusaurus/preset-classic', 39 | { 40 | docs: { 41 | path: 'docs', 42 | routeBasePath: '/', 43 | sidebarPath: require.resolve('./sidebars.js'), 44 | editUrl: 'https://github.com/amplitude/Amplitude-JavaScript/website', 45 | sidebarCollapsible: false, 46 | }, 47 | theme: { 48 | customCss: require.resolve('./src/css/custom.css'), 49 | }, 50 | }, 51 | ], 52 | ], 53 | }; 54 | -------------------------------------------------------------------------------- /test/utm.js: -------------------------------------------------------------------------------- 1 | import getUtmData from '../src/utm.js'; 2 | 3 | describe('getUtmData', function () { 4 | it('should get utm params from the query string', function () { 5 | var query = '?utm_source=amplitude&utm_medium=email&utm_term=terms' + '&utm_content=top&utm_campaign=new'; 6 | var utms = getUtmData('', query); 7 | assert.deepEqual(utms, { 8 | utm_campaign: 'new', 9 | utm_content: 'top', 10 | utm_medium: 'email', 11 | utm_source: 'amplitude', 12 | utm_term: 'terms', 13 | }); 14 | }); 15 | 16 | it('should get utm params from the cookie string', function () { 17 | var cookie = 18 | '133232535.1424926227.1.1.utmcsr=google|utmccn=(organic)' + '|utmcmd=organic|utmctr=(none)|utmcct=link'; 19 | var utms = getUtmData(cookie, ''); 20 | assert.deepEqual(utms, { 21 | utm_campaign: '(organic)', 22 | utm_content: 'link', 23 | utm_medium: 'organic', 24 | utm_source: 'google', 25 | utm_term: '(none)', 26 | }); 27 | }); 28 | 29 | it('should prefer utm params from the query string', function () { 30 | var query = '?utm_source=amplitude&utm_medium=email&utm_term=terms' + '&utm_content=top&utm_campaign=new'; 31 | var cookie = 32 | '133232535.1424926227.1.1.utmcsr=google|utmccn=(organic)' + '|utmcmd=organic|utmctr=(none)|utmcct=link'; 33 | var utms = getUtmData(cookie, query); 34 | assert.deepEqual(utms, { 35 | utm_campaign: 'new', 36 | utm_content: 'top', 37 | utm_medium: 'email', 38 | utm_source: 'amplitude', 39 | utm_term: 'terms', 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /karma.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = function(config) { 2 | const customLaunchers = { 3 | sauce_ie_8: { 4 | base: 'SauceLabs', 5 | browserName: 'internet explorer', 6 | platform: 'Windows 7', 7 | version: '8' 8 | }, 9 | sauce_ie9: { 10 | base: 'SauceLabs', 11 | browserName: 'internet explorer', 12 | platform: 'Windows 7', 13 | version: '9.0' 14 | }, 15 | sauce_edge: { 16 | base: 'SauceLabs', 17 | browserName: 'MicrosoftEdge', 18 | platform: 'Windows 10', 19 | }, 20 | sauce_chrome_windows: { 21 | base: 'SauceLabs', 22 | browserName: 'chrome', 23 | platform: 'Windows 10', 24 | }, 25 | sauce_safari_sierra: { 26 | base: 'SauceLabs', 27 | browserName: 'safari', 28 | platform: 'macOS 10.12', 29 | }, 30 | }; 31 | 32 | config.set({ 33 | sauceLabs: { 34 | testName: 'Amplitude JavaScript SDK', 35 | }, 36 | preprocessors: { 37 | '**/*.js': ['sourcemap'] 38 | }, 39 | frameworks: ['mocha', 'chai'], 40 | // files: ['amplitude-snippet.min.js', 'build/snippet-tests.js', 'build/tests.js'], @TODO: Fix flaky build/snippet-tests.js and re-enable 41 | files: ['amplitude-snippet.min.js', 'build/tests.js'], 42 | reporters: ['mocha', 'saucelabs'], 43 | port: 9876, // karma web server port 44 | colors: true, 45 | logLevel: config.LOG_INFO, 46 | customLaunchers, 47 | captureTimeout: 120000, 48 | browsers: ['ChromeHeadless'], 49 | // browsers: ['sauce_chrome_windows', 'sauce_edge', 'sauce_safari_sierra'], 50 | autoWatch: false, 51 | singleRun: true, 52 | // singleRun: false, // Karma captures browsers, runs the tests and exits 53 | concurrency: 4, 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /test/worker-storage.js: -------------------------------------------------------------------------------- 1 | import WorkerStorage from '../src/worker-storage'; 2 | 3 | describe('WorkerStorage', function () { 4 | describe('constructor', function () { 5 | it('should return an instance', function () { 6 | const workerStorage = new WorkerStorage(); 7 | assert.isTrue(workerStorage.map instanceof Map); 8 | assert.isTrue(workerStorage.length === 0); 9 | }); 10 | }); 11 | 12 | describe('key', function () { 13 | it('should return one', function () { 14 | const workerStorage = new WorkerStorage(); 15 | workerStorage.setItem('0', 'zero'); 16 | workerStorage.setItem('1', 'one'); 17 | assert.isTrue(workerStorage.key(1) === 'one'); 18 | assert.isTrue(workerStorage.length === 2); 19 | }); 20 | }); 21 | 22 | describe('setItem/getItem', function () { 23 | it('should assign and return zero', function () { 24 | const workerStorage = new WorkerStorage(); 25 | workerStorage.setItem('0', 'zero'); 26 | assert.isTrue(workerStorage.getItem('0') === 'zero'); 27 | assert.isTrue(workerStorage.length === 1); 28 | }); 29 | }); 30 | 31 | describe('removeItem', function () { 32 | it('should remove single item', function () { 33 | const workerStorage = new WorkerStorage(); 34 | workerStorage.setItem('0', 'zero'); 35 | workerStorage.removeItem('0'); 36 | assert.isTrue(workerStorage.getItem('0') === undefined); 37 | assert.isTrue(workerStorage.length === 0); 38 | }); 39 | }); 40 | 41 | describe('clear', function () { 42 | it('should clear storage', function () { 43 | const workerStorage = new WorkerStorage(); 44 | workerStorage.setItem('0', 'zero'); 45 | workerStorage.setItem('1', 'one'); 46 | workerStorage.clear(); 47 | assert.isTrue(workerStorage.getItem('0') === undefined); 48 | assert.isTrue(workerStorage.getItem('1') === undefined); 49 | assert.isTrue(workerStorage.length === 0); 50 | }); 51 | }); 52 | }); 53 | -------------------------------------------------------------------------------- /src/config-manager.js: -------------------------------------------------------------------------------- 1 | import Constants from './constants'; 2 | import { getDynamicConfigApi } from './server-zone'; 3 | import GlobalScope from './global-scope'; 4 | /** 5 | * Dynamic Configuration 6 | * Find the best server url automatically based on app users' geo location. 7 | */ 8 | class ConfigManager { 9 | constructor() { 10 | if (!ConfigManager.instance) { 11 | this.ingestionEndpoint = Constants.EVENT_LOG_URL; 12 | ConfigManager.instance = this; 13 | } 14 | return ConfigManager.instance; 15 | } 16 | 17 | refresh(serverZone, forceHttps, callback) { 18 | let protocol = 'https'; 19 | if (!forceHttps && 'https:' !== GlobalScope.location.protocol) { 20 | protocol = 'http'; 21 | } 22 | const dynamicConfigUrl = protocol + '://' + getDynamicConfigApi(serverZone); 23 | const self = this; 24 | const isIE = GlobalScope.XDomainRequest ? true : false; 25 | if (isIE) { 26 | const xdr = new GlobalScope.XDomainRequest(); 27 | xdr.open('GET', dynamicConfigUrl, true); 28 | xdr.onload = function () { 29 | const response = JSON.parse(xdr.responseText); 30 | self.ingestionEndpoint = response['ingestionEndpoint']; 31 | if (callback) { 32 | callback(); 33 | } 34 | }; 35 | xdr.onerror = function () {}; 36 | xdr.ontimeout = function () {}; 37 | xdr.onprogress = function () {}; 38 | xdr.send(); 39 | } else { 40 | var xhr = new XMLHttpRequest(); 41 | xhr.open('GET', dynamicConfigUrl, true); 42 | xhr.onreadystatechange = function () { 43 | if (xhr.readyState === 4 && xhr.status === 200) { 44 | const response = JSON.parse(xhr.responseText); 45 | self.ingestionEndpoint = response['ingestionEndpoint']; 46 | if (callback) { 47 | callback(); 48 | } 49 | } 50 | }; 51 | xhr.send(); 52 | } 53 | } 54 | } 55 | 56 | const instance = new ConfigManager(); 57 | 58 | export default instance; 59 | -------------------------------------------------------------------------------- /test/browser/amplitudejs-segment.html: -------------------------------------------------------------------------------- 1 | 2 | 14 | 15 | 25 | 26 | 27 |

Amplitude JS Test with Segment

28 |
    29 |
  • Set user ID
  • 30 |
  • Log event
  • 31 |
  • Log 32 | event with event properties
  • 33 |
  • Set user properties
  • 34 | 35 | 36 | -------------------------------------------------------------------------------- /.github/workflows/deploy-docs.yml: -------------------------------------------------------------------------------- 1 | name: Publish documentation on GitHub Pages 2 | 3 | on: workflow_dispatch 4 | 5 | jobs: 6 | authorize: 7 | name: Authorize 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: ${{ github.actor }} permission check to do a release 11 | uses: "lannonbr/repo-permission-check-action@2.0.2" 12 | with: 13 | permission: "write" 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | 17 | gh-pages: 18 | name: Publish to GitHub Pages 19 | runs-on: ubuntu-latest 20 | needs: [authorize] 21 | 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v1 25 | 26 | - name: node_modules cache 27 | uses: actions/cache@v2 28 | with: 29 | path: '**/node_modules' 30 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 31 | 32 | - name: Setup Node.js 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: 16.x 36 | 37 | - name: Install dependencies 38 | run: yarn install --frozen-lockfile && yarn docs:install --frozen-lockfile 39 | 40 | - name: Generate website assets 41 | run: yarn docs:generate-jsdoc 42 | 43 | - name: Add key to allow access to repository 44 | env: 45 | SSH_AUTH_SOCK: /tmp/ssh_agent.sock 46 | run: | 47 | mkdir -p ~/.ssh 48 | ssh-keyscan github.com >> ~/.ssh/known_hosts 49 | echo "${{ secrets.GH_PAGES_DEPLOY }}" > ~/.ssh/id_rsa 50 | chmod 600 ~/.ssh/id_rsa 51 | cat <> ~/.ssh/config 52 | Host github.com 53 | HostName github.com 54 | IdentityFile ~/.ssh/id_rsa 55 | EOT 56 | 57 | - name: Release to GitHub Pages 58 | env: 59 | USE_SSH: true 60 | GIT_USER: amplitude-sdk-bot 61 | run: | 62 | git config --global user.email "amplitude-sdk-bot@users.noreply.github.com" 63 | git config --global user.name "amplitude-sdk-bot" 64 | yarn docs:deploy 65 | -------------------------------------------------------------------------------- /.github/workflows/publish-github-packages.yml: -------------------------------------------------------------------------------- 1 | name: Publish to Github Packages 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | packageVersion: 7 | description: "The version to publish (e.g. prerelease, patch, major, new-version 1.2.3)" 8 | required: true 9 | default: "prerelease" 10 | distTag: 11 | description: "The dist-tag to publish (e.g. latest, beta)" 12 | required: true 13 | default: "latest" 14 | 15 | jobs: 16 | authorize: 17 | name: Authorize 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: ${{ github.actor }} permission check to do a release 21 | uses: "lannonbr/repo-permission-check-action@2.0.2" 22 | with: 23 | permission: "write" 24 | env: 25 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 26 | 27 | release: 28 | name: Publish to Github Packages 29 | runs-on: ubuntu-latest 30 | needs: [authorize] 31 | env: 32 | PACKAGE_VERSION: ${{ github.event.inputs.packageVersion }} 33 | DIST_TAG: ${{ github.event.inputs.distTag }} 34 | strategy: 35 | matrix: 36 | node-version: [ 16.x ] 37 | steps: 38 | - name: Checkout 39 | uses: actions/checkout@v2 40 | 41 | - name: Use Node.js ${{ matrix.node-version }} 42 | uses: actions/setup-node@v3 43 | with: 44 | node-version: ${{ matrix.node-version }} 45 | cache: 'yarn' 46 | 47 | - name: Install 48 | run: yarn install --frozen-lockfile 49 | 50 | - name: Build 51 | run: yarn build 52 | 53 | - name: Lint 54 | run: yarn lint 55 | 56 | - name: Test 57 | run: yarn test 58 | 59 | - name: Set registry url 60 | uses: actions/setup-node@v3 61 | with: 62 | registry-url: 'https://npm.pkg.github.com' 63 | 64 | - name: Publish 65 | run: | 66 | sed -i 's,"name": "amplitude-js","name": "@amplitude/amplitude-js",' package.json 67 | cat package.json 68 | yarn publish --${{ env.PACKAGE_VERSION }} --tag ${{ env.DIST_TAG }} --no-git-tag-version 69 | env: 70 | NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 71 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export default { 2 | DEFAULT_INSTANCE: '$default_instance', 3 | API_VERSION: 2, 4 | MAX_STRING_LENGTH: 4096, 5 | MAX_PROPERTY_KEYS: 1000, 6 | IDENTIFY_EVENT: '$identify', 7 | GROUP_IDENTIFY_EVENT: '$groupidentify', 8 | EVENT_LOG_URL: 'api.amplitude.com', 9 | EVENT_LOG_EU_URL: 'api.eu.amplitude.com', 10 | DYNAMIC_CONFIG_URL: 'regionconfig.amplitude.com', 11 | DYNAMIC_CONFIG_EU_URL: 'regionconfig.eu.amplitude.com', 12 | 13 | // localStorageKeys 14 | LAST_EVENT_ID: 'amplitude_lastEventId', 15 | LAST_EVENT_TIME: 'amplitude_lastEventTime', 16 | LAST_IDENTIFY_ID: 'amplitude_lastIdentifyId', 17 | LAST_SEQUENCE_NUMBER: 'amplitude_lastSequenceNumber', 18 | SESSION_ID: 'amplitude_sessionId', 19 | 20 | // Used in cookie as well 21 | DEVICE_ID: 'amplitude_deviceId', 22 | OPT_OUT: 'amplitude_optOut', 23 | USER_ID: 'amplitude_userId', 24 | 25 | // indexes of properties in cookie v2 storage format 26 | DEVICE_ID_INDEX: 0, 27 | USER_ID_INDEX: 1, 28 | OPT_OUT_INDEX: 2, 29 | SESSION_ID_INDEX: 3, 30 | LAST_EVENT_TIME_INDEX: 4, 31 | EVENT_ID_INDEX: 5, 32 | IDENTIFY_ID_INDEX: 6, 33 | SEQUENCE_NUMBER_INDEX: 7, 34 | 35 | COOKIE_TEST_PREFIX: 'amp_cookie_test', 36 | COOKIE_PREFIX: 'amp', 37 | 38 | // Storage options 39 | STORAGE_DEFAULT: '', 40 | STORAGE_COOKIES: 'cookies', 41 | STORAGE_NONE: 'none', 42 | STORAGE_LOCAL: 'localStorage', 43 | STORAGE_SESSION: 'sessionStorage', 44 | 45 | // revenue keys 46 | REVENUE_EVENT: 'revenue_amount', 47 | REVENUE_PRODUCT_ID: '$productId', 48 | REVENUE_QUANTITY: '$quantity', 49 | REVENUE_PRICE: '$price', 50 | REVENUE_REVENUE_TYPE: '$revenueType', 51 | 52 | AMP_DEVICE_ID_PARAM: 'amp_device_id', // url param 53 | AMP_REFERRER_PARAM: 'amp_referrer', // url param for overwriting the document.refer 54 | 55 | REFERRER: 'referrer', 56 | REFERRING_DOMAIN: 'referring_domain', 57 | 58 | // UTM Params 59 | UTM_SOURCE: 'utm_source', 60 | UTM_MEDIUM: 'utm_medium', 61 | UTM_CAMPAIGN: 'utm_campaign', 62 | UTM_TERM: 'utm_term', 63 | UTM_CONTENT: 'utm_content', 64 | 65 | ATTRIBUTION_EVENT: '[Amplitude] Attribution Captured', 66 | 67 | TRANSPORT_HTTP: 'http', 68 | TRANSPORT_BEACON: 'beacon', 69 | }; 70 | -------------------------------------------------------------------------------- /src/xhr.js: -------------------------------------------------------------------------------- 1 | import queryString from 'query-string'; 2 | import GlobalScope from './global-scope'; 3 | 4 | /* 5 | * Simple AJAX request object 6 | */ 7 | var Request = function (url, data, headers) { 8 | this.url = url; 9 | this.data = data || {}; 10 | this.headers = headers; 11 | }; 12 | 13 | const CORS_HEADER = 'Cross-Origin-Resource-Policy'; 14 | 15 | function setHeaders(xhr, headers) { 16 | for (const header in headers) { 17 | if (header === CORS_HEADER && !headers[header]) { 18 | continue; 19 | } 20 | xhr.setRequestHeader(header, headers[header]); 21 | } 22 | } 23 | 24 | Request.prototype.send = function (callback) { 25 | var isIE = GlobalScope.XDomainRequest ? true : false; 26 | if (isIE) { 27 | var xdr = new GlobalScope.XDomainRequest(); 28 | xdr.open('POST', this.url, true); 29 | xdr.onload = function () { 30 | callback(200, xdr.responseText); 31 | }; 32 | xdr.onerror = function () { 33 | // status code not available from xdr, try string matching on responseText 34 | if (xdr.responseText === 'Request Entity Too Large') { 35 | callback(413, xdr.responseText); 36 | } else { 37 | callback(500, xdr.responseText); 38 | } 39 | }; 40 | xdr.ontimeout = function () {}; 41 | xdr.onprogress = function () {}; 42 | xdr.send(queryString.stringify(this.data)); 43 | } else if (typeof XMLHttpRequest !== 'undefined') { 44 | var xhr = new XMLHttpRequest(); 45 | xhr.open('POST', this.url, true); 46 | xhr.onreadystatechange = function () { 47 | if (xhr.readyState === 4) { 48 | callback(xhr.status, xhr.responseText); 49 | } 50 | }; 51 | setHeaders(xhr, this.headers); 52 | xhr.send(queryString.stringify(this.data)); 53 | } else { 54 | let responseStatus = undefined; 55 | fetch(this.url, { 56 | method: 'POST', 57 | headers: this.headers, 58 | body: queryString.stringify(this.data), 59 | }) 60 | .then((response) => { 61 | responseStatus = response.status; 62 | return response.text(); 63 | }) 64 | .then((responseText) => { 65 | callback(responseStatus, responseText); 66 | }); 67 | } 68 | //log('sent request to ' + this.url + ' with data ' + decodeURIComponent(queryString(this.data))); 69 | }; 70 | 71 | export default Request; 72 | -------------------------------------------------------------------------------- /scripts/deploy_s3.py: -------------------------------------------------------------------------------- 1 | # Script used by CI to upload snippets to S3 2 | 3 | import argparse 4 | import os 5 | import sys 6 | from boto3 import Session 7 | from botocore.exceptions import ClientError 8 | 9 | unzipped_args = { 10 | 'ContentType': 'application/javascript', 11 | 'CacheControl': 'max-age=31536000', 12 | 'ACL': 'public-read', 13 | } 14 | zipped_args = { 15 | 'ContentType': 'application/javascript', 16 | 'CacheControl': 'max-age=31536000', 17 | 'ContentEncoding': 'gzip', 18 | 'ACL': 'public-read', 19 | } 20 | 21 | def check_exists(key): 22 | try: 23 | key.load() 24 | except ClientError as e: 25 | if e.response['Error']['Code'] == '404': 26 | return False 27 | else: 28 | return True 29 | 30 | def upload(bucket, file, args): 31 | bucket.upload_file( 32 | os.path.join('dist', file), 33 | os.path.join('libs', file), 34 | ExtraArgs=args, 35 | ) 36 | 37 | def main(): 38 | parser = argparse.ArgumentParser() 39 | parser.add_argument('--version', '-v', required=True, 40 | help='Version to deploy') 41 | args = parser.parse_args() 42 | s3 = Session().resource('s3') 43 | bucket = s3.Bucket(os.environ.get('S3_BUCKET_NAME')) 44 | 45 | files = [ 46 | f'amplitude-{args.version}.js', 47 | f'amplitude-{args.version}-min.js', 48 | f'amplitude-{args.version}.umd.js', 49 | f'amplitude-{args.version}-min.umd.js' 50 | ] 51 | for file in files: 52 | if check_exists(s3.Object(os.environ.get('S3_BUCKET_NAME'), os.path.join('libs', file))): 53 | sys.exit(f'ERROR: {file} already exists and shouldn\'t be republished. Consider releasing a new version') 54 | print(f'Uploading {file}') 55 | upload(bucket, file, unzipped_args) 56 | 57 | gz_files = [ 58 | f'amplitude-{args.version}-min.gz.js', 59 | f'amplitude-{args.version}-min.umd.gz.js' 60 | ] 61 | for file in gz_files: 62 | if check_exists(s3.Object(os.environ.get('S3_BUCKET_NAME'), file)): 63 | sys.exit(f'{file} already exists!') 64 | print(f'Uploading {file}') 65 | upload(bucket, file, zipped_args) 66 | 67 | print(f'Success: S3 upload completed. Example: https://cdn.amplitude.com/libs/amplitude-{args.version}.js') 68 | return 0 69 | 70 | if __name__ == '__main__': 71 | sys.exit(main()) 72 | -------------------------------------------------------------------------------- /src/cookiestorage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Abstraction layer for cookie storage. 3 | * Uses cookie if available, otherwise fallback to localstorage. 4 | */ 5 | 6 | import Cookie from './cookie'; 7 | import localStorage from './localstorage'; 8 | import baseCookie from './base-cookie'; 9 | import GlobalScope from './global-scope'; 10 | 11 | var cookieStorage = function () { 12 | this.storage = null; 13 | }; 14 | 15 | cookieStorage.prototype.getStorage = function (disableCookies) { 16 | if (this.storage !== null) { 17 | return this.storage; 18 | } 19 | 20 | if (!disableCookies && baseCookie.areCookiesEnabled()) { 21 | this.storage = Cookie; 22 | } else { 23 | // if cookies disabled, fallback to localstorage 24 | // note: localstorage does not persist across subdomains 25 | var keyPrefix = 'amp_cookiestore_'; 26 | this.storage = { 27 | _options: { 28 | expirationDays: undefined, 29 | domain: undefined, 30 | secure: false, 31 | }, 32 | reset: function () { 33 | this._options = { 34 | expirationDays: undefined, 35 | domain: undefined, 36 | secure: false, 37 | }; 38 | }, 39 | options: function (opts) { 40 | if (arguments.length === 0) { 41 | return this._options; 42 | } 43 | opts = opts || {}; 44 | this._options.expirationDays = opts.expirationDays || this._options.expirationDays; 45 | // localStorage is specific to subdomains 46 | this._options.domain = 47 | opts.domain || this._options.domain || (GlobalScope && GlobalScope.location && GlobalScope.location.hostname); 48 | return (this._options.secure = opts.secure || false); 49 | }, 50 | get: function (name) { 51 | try { 52 | return JSON.parse(localStorage.getItem(keyPrefix + name)); 53 | } catch (e) {} /* eslint-disable-line no-empty */ 54 | return null; 55 | }, 56 | set: function (name, value) { 57 | try { 58 | localStorage.setItem(keyPrefix + name, JSON.stringify(value)); 59 | return true; 60 | } catch (e) {} /* eslint-disable-line no-empty */ 61 | return false; 62 | }, 63 | remove: function (name) { 64 | try { 65 | localStorage.removeItem(keyPrefix + name); 66 | } catch (e) { 67 | return false; 68 | } 69 | }, 70 | }; 71 | } 72 | 73 | return this.storage; 74 | }; 75 | 76 | export default cookieStorage; 77 | -------------------------------------------------------------------------------- /test/language.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import language from '../src/language.js'; 3 | 4 | describe('language', function () { 5 | var languagesStub, languageStub, userLanguageStub; 6 | 7 | before(function () { 8 | /* Sinon is unable to stub undefined properties so let's make sure all are defined 9 | https://github.com/sinonjs/sinon/pull/1557 */ 10 | if (!('languages' in navigator)) { 11 | Object.defineProperty(navigator, 'languages', { 12 | value: null, 13 | configurable: true, 14 | writable: true, 15 | }); 16 | } 17 | if (!('language' in navigator)) { 18 | Object.defineProperty(navigator, 'language', { 19 | value: null, 20 | configurable: true, 21 | writable: true, 22 | }); 23 | } 24 | if (!('userLanguage' in navigator)) { 25 | Object.defineProperty(navigator, 'userLanguage', { 26 | value: null, 27 | configurable: true, 28 | writable: true, 29 | }); 30 | } 31 | 32 | languagesStub = sinon.stub(navigator, 'languages').value(['some-locale', 'some-other-locale']); 33 | languageStub = sinon.stub(navigator, 'language').value('some-second-locale'); 34 | userLanguageStub = sinon.stub(navigator, 'userLanguage').value('some-third-locale'); 35 | }); 36 | 37 | afterEach(function () { 38 | languagesStub.reset(); 39 | languageStub.reset(); 40 | userLanguageStub.reset(); 41 | }); 42 | 43 | after(function () { 44 | languagesStub.restore(); 45 | languageStub.restore(); 46 | userLanguageStub.restore(); 47 | }); 48 | 49 | it('should return a language', function () { 50 | assert.isNotNull(language.getLanguage()); 51 | }); 52 | 53 | it('should prioritize the first language of navigator.languages', function () { 54 | assert.equal(language.getLanguage(), 'some-locale'); 55 | }); 56 | 57 | it('should secondly use the language of navigator.language', function () { 58 | languagesStub.value(undefined); 59 | 60 | assert.equal(language.getLanguage(), 'some-second-locale'); 61 | }); 62 | 63 | it('should thirdly use the language of navigator.userLanguage', function () { 64 | languagesStub.value(undefined); 65 | languageStub.value(undefined); 66 | 67 | assert.equal(language.getLanguage(), 'some-third-locale'); 68 | }); 69 | 70 | it('should return empty string if navigator language is not set', function () { 71 | languagesStub.value(undefined); 72 | languageStub.value(undefined); 73 | userLanguageStub.value(undefined); 74 | 75 | assert.equal(language.getLanguage(), ''); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ main ] 20 | 21 | jobs: 22 | analyze: 23 | name: Analyze 24 | runs-on: ubuntu-latest 25 | permissions: 26 | actions: read 27 | contents: read 28 | security-events: write 29 | 30 | strategy: 31 | fail-fast: false 32 | matrix: 33 | language: [ 'javascript', 'python' ] 34 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 35 | # Learn more: 36 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 37 | 38 | steps: 39 | - name: Checkout repository 40 | uses: actions/checkout@v2 41 | 42 | # Initializes the CodeQL tools for scanning. 43 | - name: Initialize CodeQL 44 | uses: github/codeql-action/init@v1 45 | with: 46 | languages: ${{ matrix.language }} 47 | # If you wish to specify custom queries, you can do so here or in a config file. 48 | # By default, queries listed here will override any specified in a config file. 49 | # Prefix the list here with "+" to use these queries and those in the config file. 50 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 51 | 52 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 53 | # If this step fails, then you should remove it and run the build manually (see below) 54 | - name: Autobuild 55 | uses: github/codeql-action/autobuild@v1 56 | 57 | # ℹ️ Command-line programs to run using the OS shell. 58 | # 📚 https://git.io/JvXDl 59 | 60 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 61 | # and modify them (or add more) to build your code if your project 62 | # uses a compiled language 63 | 64 | #- run: | 65 | # make bootstrap 66 | # make release 67 | 68 | - name: Perform CodeQL Analysis 69 | uses: github/codeql-action/analyze@v1 70 | -------------------------------------------------------------------------------- /src/cookie.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Cookie data 3 | */ 4 | 5 | import Base64 from './base64'; 6 | import utils from './utils'; 7 | import baseCookie from './base-cookie'; 8 | import topDomain from './top-domain'; 9 | 10 | var _options = { 11 | expirationDays: undefined, 12 | domain: undefined, 13 | }; 14 | 15 | var reset = function () { 16 | _options = { 17 | expirationDays: undefined, 18 | domain: undefined, 19 | }; 20 | }; 21 | 22 | var options = function (opts) { 23 | if (arguments.length === 0) { 24 | return _options; 25 | } 26 | 27 | opts = opts || {}; 28 | 29 | _options.expirationDays = opts.expirationDays; 30 | _options.secure = opts.secure; 31 | _options.sameSite = opts.sameSite; 32 | 33 | var domain = !utils.isEmptyString(opts.domain) ? opts.domain : '.' + topDomain(utils.getLocation().href); 34 | var token = Math.random(); 35 | _options.domain = domain; 36 | set('amplitude_test', token); 37 | var stored = get('amplitude_test'); 38 | if (!stored || stored !== token) { 39 | domain = null; 40 | } 41 | remove('amplitude_test'); 42 | _options.domain = domain; 43 | 44 | return _options; 45 | }; 46 | 47 | var _domainSpecific = function (name) { 48 | // differentiate between cookies on different domains 49 | var suffix = ''; 50 | if (_options.domain) { 51 | suffix = _options.domain.charAt(0) === '.' ? _options.domain.substring(1) : _options.domain; 52 | } 53 | return name + suffix; 54 | }; 55 | 56 | var get = function (name) { 57 | var nameEq = _domainSpecific(name) + '='; 58 | const value = baseCookie.get(nameEq); 59 | 60 | try { 61 | if (value) { 62 | return JSON.parse(Base64.decode(value)); 63 | } 64 | } catch (e) { 65 | return null; 66 | } 67 | 68 | return null; 69 | }; 70 | 71 | var set = function (name, value) { 72 | try { 73 | baseCookie.set(_domainSpecific(name), Base64.encode(JSON.stringify(value)), _options); 74 | return true; 75 | } catch (e) { 76 | return false; 77 | } 78 | }; 79 | 80 | var setRaw = function (name, value) { 81 | try { 82 | baseCookie.set(_domainSpecific(name), value, _options); 83 | return true; 84 | } catch (e) { 85 | return false; 86 | } 87 | }; 88 | 89 | var getRaw = function (name) { 90 | var nameEq = _domainSpecific(name) + '='; 91 | return baseCookie.get(nameEq); 92 | }; 93 | 94 | var remove = function (name) { 95 | try { 96 | baseCookie.set(_domainSpecific(name), null, _options); 97 | return true; 98 | } catch (e) { 99 | return false; 100 | } 101 | }; 102 | 103 | export default { 104 | reset, 105 | options, 106 | get, 107 | set, 108 | remove, 109 | setRaw, 110 | getRaw, 111 | }; 112 | -------------------------------------------------------------------------------- /src/base64.js: -------------------------------------------------------------------------------- 1 | import UTF8 from './utf8'; 2 | import GlobalScope from './global-scope'; 3 | 4 | /* 5 | * Base64 encoder/decoder 6 | * http://www.webtoolkit.info/ 7 | */ 8 | var Base64 = { 9 | _keyStr: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=', 10 | 11 | encode: function (input) { 12 | try { 13 | if (GlobalScope.btoa && GlobalScope.atob) { 14 | return GlobalScope.btoa(unescape(encodeURIComponent(input))); 15 | } 16 | } catch (e) { 17 | //log(e); 18 | } 19 | return Base64._encode(input); 20 | }, 21 | 22 | _encode: function (input) { 23 | var output = ''; 24 | var chr1, chr2, chr3, enc1, enc2, enc3, enc4; 25 | var i = 0; 26 | 27 | input = UTF8.encode(input); 28 | 29 | while (i < input.length) { 30 | chr1 = input.charCodeAt(i++); 31 | chr2 = input.charCodeAt(i++); 32 | chr3 = input.charCodeAt(i++); 33 | 34 | enc1 = chr1 >> 2; 35 | enc2 = ((chr1 & 3) << 4) | (chr2 >> 4); 36 | enc3 = ((chr2 & 15) << 2) | (chr3 >> 6); 37 | enc4 = chr3 & 63; 38 | 39 | if (isNaN(chr2)) { 40 | enc3 = enc4 = 64; 41 | } else if (isNaN(chr3)) { 42 | enc4 = 64; 43 | } 44 | 45 | output = 46 | output + 47 | Base64._keyStr.charAt(enc1) + 48 | Base64._keyStr.charAt(enc2) + 49 | Base64._keyStr.charAt(enc3) + 50 | Base64._keyStr.charAt(enc4); 51 | } 52 | return output; 53 | }, 54 | 55 | decode: function (input) { 56 | try { 57 | if (GlobalScope.btoa && GlobalScope.atob) { 58 | return decodeURIComponent(escape(GlobalScope.atob(input))); 59 | } 60 | } catch (e) { 61 | //log(e); 62 | } 63 | return Base64._decode(input); 64 | }, 65 | 66 | _decode: function (input) { 67 | var output = ''; 68 | var chr1, chr2, chr3; 69 | var enc1, enc2, enc3, enc4; 70 | var i = 0; 71 | 72 | input = input.replace(/[^A-Za-z0-9+/=]/g, ''); 73 | 74 | while (i < input.length) { 75 | enc1 = Base64._keyStr.indexOf(input.charAt(i++)); 76 | enc2 = Base64._keyStr.indexOf(input.charAt(i++)); 77 | enc3 = Base64._keyStr.indexOf(input.charAt(i++)); 78 | enc4 = Base64._keyStr.indexOf(input.charAt(i++)); 79 | 80 | chr1 = (enc1 << 2) | (enc2 >> 4); 81 | chr2 = ((enc2 & 15) << 4) | (enc3 >> 2); 82 | chr3 = ((enc3 & 3) << 6) | enc4; 83 | 84 | output = output + String.fromCharCode(chr1); 85 | 86 | if (enc3 !== 64) { 87 | output = output + String.fromCharCode(chr2); 88 | } 89 | if (enc4 !== 64) { 90 | output = output + String.fromCharCode(chr3); 91 | } 92 | } 93 | output = UTF8.decode(output); 94 | return output; 95 | }, 96 | }; 97 | 98 | export default Base64; 99 | -------------------------------------------------------------------------------- /.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 | authorize: 13 | name: Authorize 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: ${{ github.actor }} permission check to do a release 17 | uses: "lannonbr/repo-permission-check-action@2.0.2" 18 | with: 19 | permission: "write" 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | 23 | release: 24 | name: Release 25 | runs-on: ubuntu-latest 26 | needs: [authorize] 27 | permissions: 28 | id-token: write 29 | contents: write 30 | env: 31 | GIT_AUTHOR_NAME: amplitude-sdk-bot 32 | GIT_AUTHOR_EMAIL: amplitude-sdk-bot@users.noreply.github.com 33 | GIT_COMMITTER_NAME: amplitude-sdk-bot 34 | GIT_COMMITTER_EMAIL: amplitude-sdk-bot@users.noreply.github.com 35 | 36 | steps: 37 | - name: Checkout 38 | uses: actions/checkout@v1 39 | 40 | - name: Configure AWS Credentials 41 | uses: aws-actions/configure-aws-credentials@v1 42 | with: 43 | role-to-assume: arn:aws:iam::358203115967:role/github-actions-role 44 | aws-region: us-west-2 45 | 46 | - name: node_modules cache 47 | uses: actions/cache@v2 48 | with: 49 | path: '**/node_modules' 50 | key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }} 51 | 52 | - name: Set up Python 53 | uses: actions/setup-python@v2 54 | with: 55 | python-version: '3.8.x' 56 | - name: Install boto3 for deploy_s3.python 57 | run: pip install boto3==1.14.63 58 | 59 | - name: Setup Node.js 60 | uses: actions/setup-node@v3 61 | with: 62 | node-version: 16.x 63 | 64 | - name: Install dependencies 65 | run: yarn install --frozen-lockfile 66 | 67 | - name: Run tests 68 | run: make test 69 | 70 | - name: Release --dry-run # Uses release.config.js 71 | if: ${{ github.event.inputs.dryRun == 'true'}} 72 | env: 73 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 74 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 75 | S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} 76 | run: npx semantic-release --dry-run 77 | 78 | - name: Release # Uses release.config.js 79 | if: ${{ github.event.inputs.dryRun == 'false'}} 80 | env: 81 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 82 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 83 | S3_BUCKET_NAME: ${{ secrets.S3_BUCKET_NAME }} 84 | run: npx semantic-release 85 | -------------------------------------------------------------------------------- /test/cookiestorage.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import localStorage from '../src/localstorage.js'; 3 | import CookieStorage from '../src/cookiestorage.js'; 4 | import cookie from '../src/cookie.js'; 5 | import baseCookie from '../src/base-cookie.js'; 6 | import Amplitude from '../src/amplitude.js'; 7 | 8 | describe('cookieStorage', function () { 9 | new Amplitude(); 10 | var keyPrefix = 'amp_cookiestore_'; 11 | 12 | describe('getStorage', function () { 13 | it('should use cookies if enabled', function () { 14 | var cookieStorage = new CookieStorage(); 15 | assert.isTrue(baseCookie.areCookiesEnabled()); 16 | 17 | localStorage.clear(); 18 | var uid = String(new Date()); 19 | cookieStorage.getStorage().set(uid, uid); 20 | assert.equal(cookieStorage.getStorage().get(uid), uid); 21 | assert.equal(cookie.get(uid), uid); 22 | assert.equal(cookieStorage.getStorage().get(uid), cookie.get(uid)); 23 | 24 | cookieStorage.getStorage().remove(uid); 25 | assert.isNull(cookieStorage.getStorage().get(uid)); 26 | assert.isNull(cookie.get(uid)); 27 | 28 | // assert nothing added to localstorage 29 | assert.isNull(localStorage.getItem(keyPrefix + uid)); 30 | }); 31 | 32 | it('should fall back to localstorage if cookies disabled', function () { 33 | var cookieStorage = new CookieStorage(); 34 | const stub = sinon.stub(baseCookie, 'areCookiesEnabled').returns(false); 35 | assert.isFalse(baseCookie.areCookiesEnabled()); 36 | 37 | localStorage.clear(); 38 | var uid = String(new Date()); 39 | cookieStorage.getStorage().set(uid, uid); 40 | assert.equal(cookieStorage.getStorage().get(uid), uid); 41 | assert.equal(localStorage.getItem(keyPrefix + uid), JSON.stringify(uid)); 42 | assert.equal(cookieStorage.getStorage().get(uid), JSON.parse(localStorage.getItem(keyPrefix + uid))); 43 | 44 | cookieStorage.getStorage().remove(uid); 45 | assert.isNull(cookieStorage.getStorage().get(uid)); 46 | assert.isNull(localStorage.getItem(keyPrefix + uid)); 47 | 48 | // assert nothing added to cookie 49 | assert.isNull(cookie.get(uid)); 50 | stub.restore(); 51 | }); 52 | 53 | it('should load data from localstorage if cookies disabled', function () { 54 | var cookieStorage = new CookieStorage(); 55 | const stub = sinon.stub(baseCookie, 'areCookiesEnabled').returns(false); 56 | assert.isFalse(baseCookie.areCookiesEnabled()); 57 | 58 | localStorage.clear(); 59 | var uid = String(new Date()); 60 | localStorage.setItem(keyPrefix + uid, JSON.stringify(uid)); 61 | assert.equal(cookieStorage.getStorage().get(uid), uid); 62 | 63 | localStorage.removeItem(keyPrefix + uid); 64 | assert.isNull(cookieStorage.getStorage().get(uid)); 65 | stub.restore(); 66 | }); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | SRC = $(wildcard src/*.js) 2 | SNIPPET = src/amplitude-snippet.js 3 | TESTS = $(wildcard test/*.js) 4 | BINS = node_modules/.bin 5 | MINIFY = $(BINS)/uglifyjs 6 | JSDOC = $(BINS)/jsdoc 7 | BUILD_DIR = build 8 | PROJECT = amplitude 9 | OUT = $(PROJECT).js 10 | SNIPPET_OUT = $(PROJECT)-snippet.min.js 11 | SEGMENT_SNIPPET_OUT = $(PROJECT)-segment-snippet.min.js 12 | MIN_OUT = $(PROJECT).min.js 13 | MOCHA = $(BINS)/mocha-phantomjs 14 | KARMA = $(BINS)/karma 15 | ROLLUP = $(BINS)/rollup 16 | 17 | # 18 | # Default target. 19 | # 20 | 21 | default: test 22 | 23 | # 24 | # Clean. 25 | # 26 | 27 | clean: 28 | @-rm -f amplitude.js amplitude.min.js 29 | @-rm -rf node_modules npm-debug.log 30 | 31 | 32 | # 33 | # Test. 34 | # 35 | 36 | test: build 37 | @$(KARMA) start karma.conf.js 38 | @$(KARMA) start karma-web-worker.conf.js 39 | 40 | test-sauce: build 41 | @$(KARMA) start karma.conf.js --browsers sauce_chrome_windows 42 | 43 | 44 | # 45 | # Target for `node_modules` folder. 46 | # 47 | 48 | node_modules: package.json 49 | @yarn 50 | 51 | # 52 | # Target for updating version. 53 | 54 | version: package.json 55 | node scripts/version 56 | 57 | # 58 | # Target for updating readme. 59 | 60 | README.md: version $(SNIPPET_OUT) 61 | node scripts/readme 62 | 63 | # 64 | # Target for `amplitude.js` file. 65 | # 66 | 67 | $(OUT): node_modules $(SRC) package.json rollup.config.js rollup.min.js rollup.esm.js rollup.umd.js rollup.umd.min.js 68 | @NODE_ENV=production $(ROLLUP) --config rollup.config.js # is the snippet build config 69 | @NODE_ENV=production $(ROLLUP) --config rollup.esm.js # does not concat dependencies, only has module and dependencies 70 | @NODE_ENV=production $(ROLLUP) --config rollup.umd.js # generates npm version, also usable in require js app 71 | @NODE_ENV=production $(ROLLUP) --config rollup.umd.min.js 72 | @NODE_ENV=production $(ROLLUP) --config rollup.min.js 73 | 74 | # 75 | # Target for minified `amplitude-snippet.js` file. 76 | # 77 | $(SNIPPET_OUT): $(SRC) $(SNIPPET) 78 | @$(MINIFY) $(SNIPPET) -m -b max_line_len=80,beautify=false | awk 'NF' > $(SNIPPET_OUT) 79 | 80 | $(SEGMENT_SNIPPET_OUT): $(SRC) $(SNIPPET) 81 | @sed -n '/createElement/,/insertBefore/!p' $(SNIPPET) | $(MINIFY) -m -b max_line_len=80,beautify=false - \ 82 | | awk 'NF' > $(SEGMENT_SNIPPET_OUT) 83 | 84 | # 85 | # Target for `tests-build.js` file. 86 | # 87 | 88 | build: $(TESTS) $(OUT) $(SNIPPET_OUT) $(SEGMENT_SNIPPET_OUT) 89 | @$(ROLLUP) --config rollup.test.js 90 | @$(ROLLUP) --config rollup.snippet-tests.js 91 | @-echo "Done building" 92 | 93 | docs: 94 | @$(JSDOC) -d ./documentation/ src/*.js 95 | 96 | # 97 | # Target for release. 98 | # 99 | 100 | release: $(OUT) $(SNIPPET_OUT) README.md 101 | @-mkdir -p dist 102 | node scripts/release 103 | 104 | .PHONY: clean 105 | .PHONY: test 106 | -------------------------------------------------------------------------------- /.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 | name: Check PR for semantic title 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: PR title is valid 13 | if: > 14 | startsWith(github.event.pull_request.title, 'feat:') || startsWith(github.event.pull_request.title, 'feat(') || 15 | startsWith(github.event.pull_request.title, 'fix:') || startsWith(github.event.pull_request.title, 'fix(') || 16 | startsWith(github.event.pull_request.title, 'perf:') || startsWith(github.event.pull_request.title, 'perf(') || 17 | startsWith(github.event.pull_request.title, 'docs:') || startsWith(github.event.pull_request.title, 'docs(') || 18 | startsWith(github.event.pull_request.title, 'test:') || startsWith(github.event.pull_request.title, 'test(') || 19 | startsWith(github.event.pull_request.title, 'refactor:') || startsWith(github.event.pull_request.title, 'refactor(') || 20 | startsWith(github.event.pull_request.title, 'style:') || startsWith(github.event.pull_request.title, 'style(') || 21 | startsWith(github.event.pull_request.title, 'build:') || startsWith(github.event.pull_request.title, 'build(') || 22 | startsWith(github.event.pull_request.title, 'ci:') || startsWith(github.event.pull_request.title, 'ci(') || 23 | startsWith(github.event.pull_request.title, 'chore:') || startsWith(github.event.pull_request.title, 'chore(') || 24 | startsWith(github.event.pull_request.title, 'revert:') || startsWith(github.event.pull_request.title, 'revert(') 25 | run: | 26 | echo 'Title checks passed' 27 | 28 | - name: PR title is invalid 29 | if: > 30 | !startsWith(github.event.pull_request.title, 'feat:') && !startsWith(github.event.pull_request.title, 'feat(') && 31 | !startsWith(github.event.pull_request.title, 'fix:') && !startsWith(github.event.pull_request.title, 'fix(') && 32 | !startsWith(github.event.pull_request.title, 'perf:') && !startsWith(github.event.pull_request.title, 'perf(') && 33 | !startsWith(github.event.pull_request.title, 'docs:') && !startsWith(github.event.pull_request.title, 'docs(') && 34 | !startsWith(github.event.pull_request.title, 'test:') && !startsWith(github.event.pull_request.title, 'test(') && 35 | !startsWith(github.event.pull_request.title, 'refactor:') && !startsWith(github.event.pull_request.title, 'refactor(') && 36 | !startsWith(github.event.pull_request.title, 'style:') && !startsWith(github.event.pull_request.title, 'style(') && 37 | !startsWith(github.event.pull_request.title, 'build:') && !startsWith(github.event.pull_request.title, 'build(') && 38 | !startsWith(github.event.pull_request.title, 'ci:') && !startsWith(github.event.pull_request.title, 'ci(') && 39 | !startsWith(github.event.pull_request.title, 'chore:') && !startsWith(github.event.pull_request.title, 'chore(') && 40 | !startsWith(github.event.pull_request.title, 'revert:') && !startsWith(github.event.pull_request.title, 'revert(') 41 | run: | 42 | echo 'Pull request title is not valid. Please check github.com/amplitude/Amplitude-JavaScript/blob/main/CONTRIBUTING.md#pr-commit-title-conventions' 43 | exit 1 44 | -------------------------------------------------------------------------------- /src/base-cookie.js: -------------------------------------------------------------------------------- 1 | import Constants from './constants'; 2 | import utils from './utils'; 3 | 4 | const get = (name) => { 5 | try { 6 | const ca = document.cookie.split(';'); 7 | let value = null; 8 | for (let i = 0; i < ca.length; i++) { 9 | let c = ca[i]; 10 | while (c.charAt(0) === ' ') { 11 | c = c.substring(1, c.length); 12 | } 13 | if (c.indexOf(name) === 0) { 14 | value = c.substring(name.length, c.length); 15 | break; 16 | } 17 | } 18 | 19 | return value; 20 | } catch (e) { 21 | return null; 22 | } 23 | }; 24 | 25 | const getAll = (name) => { 26 | try { 27 | const cookieArray = document.cookie.split(';').map((c) => c.trimStart()); 28 | let values = []; 29 | for (let cookie of cookieArray) { 30 | while (cookie.charAt(0) === ' ') { 31 | cookie = cookie.substring(1); 32 | } 33 | if (cookie.indexOf(name) === 0) { 34 | values.push(cookie.substring(name.length)); 35 | } 36 | } 37 | 38 | return values; 39 | } catch (e) { 40 | return []; 41 | } 42 | }; 43 | 44 | const set = (name, value, opts) => { 45 | let expires = value !== null ? opts.expirationDays : -1; 46 | if (expires) { 47 | const date = new Date(); 48 | date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000); 49 | expires = date; 50 | } 51 | let str = name + '=' + value; 52 | if (expires) { 53 | str += '; expires=' + expires.toUTCString(); 54 | } 55 | str += '; path=/'; 56 | if (opts.domain) { 57 | str += '; domain=' + opts.domain; 58 | } 59 | if (opts.secure) { 60 | str += '; Secure'; 61 | } 62 | if (opts.sameSite) { 63 | str += '; SameSite=' + opts.sameSite; 64 | } 65 | document.cookie = str; 66 | }; 67 | 68 | const getLastEventTime = (cookie = '') => { 69 | const strValue = cookie.split('.')[Constants.LAST_EVENT_TIME_INDEX]; 70 | 71 | let parsedValue; 72 | if (strValue) { 73 | parsedValue = parseInt(strValue, 32); 74 | } 75 | 76 | if (parsedValue) { 77 | return parsedValue; 78 | } else { 79 | utils.log.warn(`unable to parse malformed cookie: ${cookie}`); 80 | return 0; 81 | } 82 | }; 83 | 84 | const sortByEventTime = (cookies) => { 85 | return [...cookies].sort((c1, c2) => { 86 | const t1 = getLastEventTime(c1); 87 | const t2 = getLastEventTime(c2); 88 | // sort c1 first if its last event time is more recent 89 | // i.e its event time integer is larger that c2's 90 | return t2 - t1; 91 | }); 92 | }; 93 | 94 | // test that cookies are enabled - navigator.cookiesEnabled yields false positives in IE, need to test directly 95 | const areCookiesEnabled = (opts = {}) => { 96 | const cookieName = Constants.COOKIE_TEST_PREFIX; 97 | if (typeof document === 'undefined') { 98 | return false; 99 | } 100 | let _areCookiesEnabled = false; 101 | try { 102 | const uid = String(Date.now()); 103 | set(cookieName, uid, opts); 104 | utils.log.info(`Testing if cookies available`); 105 | _areCookiesEnabled = get(cookieName + '=') === uid; 106 | } catch (e) { 107 | utils.log.warn(`Error thrown when checking for cookies. Reason: "${e}"`); 108 | } finally { 109 | utils.log.info(`Cleaning up cookies availability test`); 110 | set(cookieName, null, opts); 111 | } 112 | return _areCookiesEnabled; 113 | }; 114 | 115 | export default { 116 | set, 117 | get, 118 | getAll, 119 | getLastEventTime, 120 | sortByEventTime, 121 | areCookiesEnabled, 122 | }; 123 | -------------------------------------------------------------------------------- /test/snippet-tests.js: -------------------------------------------------------------------------------- 1 | import '../amplitude-snippet.min.js'; 2 | 3 | describe('Snippet', function () { 4 | it('amplitude object should exist', function () { 5 | assert.isObject(window.amplitude); 6 | assert.isFunction(window.amplitude.init); 7 | assert.isFunction(window.amplitude.logEvent); 8 | }); 9 | 10 | it('amplitude object should proxy functions', function () { 11 | amplitude.init('API_KEY'); 12 | amplitude.logEvent('Event', { prop: 1 }); 13 | assert.lengthOf(amplitude._q, 2); 14 | assert.deepEqual(amplitude._q[0], ['init', 'API_KEY']); 15 | }); 16 | 17 | it('amplitude object should proxy Identify object and calls', function () { 18 | var identify = new amplitude.Identify().set('key1', 'value1').unset('key2'); 19 | identify.add('key3', 2).setOnce('key4', 'value2'); 20 | 21 | assert.lengthOf(identify._q, 4); 22 | assert.deepEqual(identify._q[0], ['set', 'key1', 'value1']); 23 | assert.deepEqual(identify._q[1], ['unset', 'key2']); 24 | assert.deepEqual(identify._q[2], ['add', 'key3', 2]); 25 | assert.deepEqual(identify._q[3], ['setOnce', 'key4', 'value2']); 26 | }); 27 | 28 | it('amplitude object should proxy Revenue object and calls', function () { 29 | var revenue = new amplitude.Revenue().setProductId('productIdentifier').setQuantity(5).setPrice(10.99); 30 | assert.lengthOf(revenue._q, 3); 31 | assert.deepEqual(revenue._q[0], ['setProductId', 'productIdentifier']); 32 | assert.deepEqual(revenue._q[1], ['setQuantity', 5]); 33 | assert.deepEqual(revenue._q[2], ['setPrice', 10.99]); 34 | }); 35 | 36 | it('amplitude object should proxy instance functions', function () { 37 | amplitude.getInstance(null).init('API_KEY'); 38 | amplitude.getInstance('$DEFAULT_instance').logEvent('Click'); 39 | amplitude.getInstance('').clearUserProperties(); 40 | amplitude.getInstance('INSTANCE1').init('API_KEY1'); 41 | amplitude.getInstance('instanCE2').init('API_KEY2'); 42 | amplitude.getInstance('instaNce2').logEvent('Event'); 43 | 44 | assert.deepEqual(Object.keys(amplitude._iq), ['$default_instance', 'instance1', 'instance2']); 45 | assert.lengthOf(amplitude._iq['$default_instance']._q, 3); 46 | assert.deepEqual(amplitude._iq['$default_instance']._q[0], ['init', 'API_KEY']); 47 | assert.deepEqual(amplitude._iq['$default_instance']._q[1], ['logEvent', 'Click']); 48 | assert.deepEqual(amplitude._iq['$default_instance']._q[2], ['clearUserProperties']); 49 | assert.lengthOf(amplitude._iq['instance1']._q, 1); 50 | assert.deepEqual(amplitude._iq['instance1']._q[0], ['init', 'API_KEY1']); 51 | assert.lengthOf(amplitude._iq['instance2']._q, 2); 52 | assert.deepEqual(amplitude._iq['instance2']._q[0], ['init', 'API_KEY2']); 53 | assert.deepEqual(amplitude._iq['instance2']._q[1], ['logEvent', 'Event']); 54 | }); 55 | 56 | it('amplitude object should proxy onInit', function () { 57 | const callback = () => {}; 58 | amplitude.getInstance('onInit').onInit(callback); 59 | amplitude.getInstance('onInit').init('API_KEY'); 60 | amplitude.getInstance('onInit').logEvent('Event', { prop: 1 }); 61 | assert.lengthOf(amplitude._iq['oninit']._q, 3); 62 | assert.deepEqual(amplitude._iq['oninit']._q[0], ['onInit', callback]); 63 | }); 64 | 65 | it('amplitude object should proxy resetSessionId', function () { 66 | amplitude.getInstance('reset_session_id_instance').init('API_KEY'); 67 | amplitude.getInstance('reset_session_id_instance').resetSessionId(); 68 | assert.deepEqual(amplitude._iq['reset_session_id_instance']._q[1], ['resetSessionId']); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to the Amplitude SDK for JavaScript 2 | 3 | 🎉 Thanks for your interest in contributing! 🎉 4 | 5 | ## Ramping Up 6 | 7 | ### Intro 8 | 9 | - There are three ways for SDK to be loaded 10 | - Standard NPM package 11 | - Snippet in ` 4 | 51 | 56 | 57 | 58 |

    Amplitude JS Test with RequireJS

    59 |
      60 |
    • Set user ID
    • 61 |
    • Toggle opt out
    • 62 |
    • Log event
    • 63 |
    • Log 64 | event with event properties
    • 65 |
    • Set user properties
    • 66 |
    • Toggle batch events
    • 67 |
    • Set event upload threshold
    • 68 |
    • Click on link A
    • 69 |

      Testing Identify calls
      70 |
    • Add to photo count
    • 71 |
    • Unset photo count
    • 72 |
    • Set photo count
    • 73 |
    • Set photo count once
    • 74 |
    • Set city via setUserProperties
    • 75 |
    • Clear all user properties
    • 76 |

      77 |
    • Go to second page
    • 78 | 79 | 80 | -------------------------------------------------------------------------------- /src/amplitude-snippet.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Imported in client browser via 61 | 70 | 71 |

      Amplitude JS Test

      72 |
        73 |
      • Set user ID
      • 74 |
      • Toggle opt out
      • 75 |
      • Log event
      • 76 |
      • Log 77 | event with event properties
      • 78 |
      • Set user properties
      • 79 |
      • Toggle batch events
      • 80 |
      • Set event upload threshold
      • 81 |
      • Click on link A
      • 82 |

        Testing Identify calls
        83 |
      • Add to photo count
      • 84 |
      • Unset photo count
      • 85 |
      • Set photo count
      • 86 |
      • Set photo count once
      • 87 |
      • Set city via setUserProperties
      • 88 |
      • Clear all user properties
      • 89 |

        90 |
      • Go to first page
      • 91 | 92 | 93 | -------------------------------------------------------------------------------- /test/browser/mocha.css: -------------------------------------------------------------------------------- 1 | @charset "utf-8"; 2 | 3 | body { 4 | margin:0; 5 | } 6 | 7 | #mocha { 8 | font: 20px/1.5 "Helvetica Neue", Helvetica, Arial, sans-serif; 9 | margin: 60px 50px; 10 | } 11 | 12 | #mocha ul, 13 | #mocha li { 14 | margin: 0; 15 | padding: 0; 16 | } 17 | 18 | #mocha ul { 19 | list-style: none; 20 | } 21 | 22 | #mocha h1, 23 | #mocha h2 { 24 | margin: 0; 25 | } 26 | 27 | #mocha h1 { 28 | margin-top: 15px; 29 | font-size: 1em; 30 | font-weight: 200; 31 | } 32 | 33 | #mocha h1 a { 34 | text-decoration: none; 35 | color: inherit; 36 | } 37 | 38 | #mocha h1 a:hover { 39 | text-decoration: underline; 40 | } 41 | 42 | #mocha .suite .suite h1 { 43 | margin-top: 0; 44 | font-size: .8em; 45 | } 46 | 47 | #mocha .hidden { 48 | display: none; 49 | } 50 | 51 | #mocha h2 { 52 | font-size: 12px; 53 | font-weight: normal; 54 | cursor: pointer; 55 | } 56 | 57 | #mocha .suite { 58 | margin-left: 15px; 59 | } 60 | 61 | #mocha .test { 62 | margin-left: 15px; 63 | overflow: hidden; 64 | } 65 | 66 | #mocha .test.pending:hover h2::after { 67 | content: '(pending)'; 68 | font-family: arial, sans-serif; 69 | } 70 | 71 | #mocha .test.pass.medium .duration { 72 | background: #c09853; 73 | } 74 | 75 | #mocha .test.pass.slow .duration { 76 | background: #b94a48; 77 | } 78 | 79 | #mocha .test.pass::before { 80 | content: '✓'; 81 | font-size: 12px; 82 | display: block; 83 | float: left; 84 | margin-right: 5px; 85 | color: #00d6b2; 86 | } 87 | 88 | #mocha .test.pass .duration { 89 | font-size: 9px; 90 | margin-left: 5px; 91 | padding: 2px 5px; 92 | color: #fff; 93 | -webkit-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 94 | -moz-box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 95 | box-shadow: inset 0 1px 1px rgba(0,0,0,.2); 96 | -webkit-border-radius: 5px; 97 | -moz-border-radius: 5px; 98 | -ms-border-radius: 5px; 99 | -o-border-radius: 5px; 100 | border-radius: 5px; 101 | } 102 | 103 | #mocha .test.pass.fast .duration { 104 | display: none; 105 | } 106 | 107 | #mocha .test.pending { 108 | color: #0b97c4; 109 | } 110 | 111 | #mocha .test.pending::before { 112 | content: '◦'; 113 | color: #0b97c4; 114 | } 115 | 116 | #mocha .test.fail { 117 | color: #c00; 118 | } 119 | 120 | #mocha .test.fail pre { 121 | color: black; 122 | } 123 | 124 | #mocha .test.fail::before { 125 | content: '✖'; 126 | font-size: 12px; 127 | display: block; 128 | float: left; 129 | margin-right: 5px; 130 | color: #c00; 131 | } 132 | 133 | #mocha .test pre.error { 134 | color: #c00; 135 | max-height: 300px; 136 | overflow: auto; 137 | } 138 | 139 | /** 140 | * (1): approximate for browsers not supporting calc 141 | * (2): 42 = 2*15 + 2*10 + 2*1 (padding + margin + border) 142 | * ^^ seriously 143 | */ 144 | #mocha .test pre { 145 | display: block; 146 | float: left; 147 | clear: left; 148 | font: 12px/1.5 monaco, monospace; 149 | margin: 5px; 150 | padding: 15px; 151 | border: 1px solid #eee; 152 | max-width: 85%; /*(1)*/ 153 | max-width: calc(100% - 42px); /*(2)*/ 154 | word-wrap: break-word; 155 | border-bottom-color: #ddd; 156 | -webkit-border-radius: 3px; 157 | -webkit-box-shadow: 0 1px 3px #eee; 158 | -moz-border-radius: 3px; 159 | -moz-box-shadow: 0 1px 3px #eee; 160 | border-radius: 3px; 161 | } 162 | 163 | #mocha .test h2 { 164 | position: relative; 165 | } 166 | 167 | #mocha .test a.replay { 168 | position: absolute; 169 | top: 3px; 170 | right: 0; 171 | text-decoration: none; 172 | vertical-align: middle; 173 | display: block; 174 | width: 15px; 175 | height: 15px; 176 | line-height: 15px; 177 | text-align: center; 178 | background: #eee; 179 | font-size: 15px; 180 | -moz-border-radius: 15px; 181 | border-radius: 15px; 182 | -webkit-transition: opacity 200ms; 183 | -moz-transition: opacity 200ms; 184 | transition: opacity 200ms; 185 | opacity: 0.3; 186 | color: #888; 187 | } 188 | 189 | #mocha .test:hover a.replay { 190 | opacity: 1; 191 | } 192 | 193 | #mocha-report.pass .test.fail { 194 | display: none; 195 | } 196 | 197 | #mocha-report.fail .test.pass { 198 | display: none; 199 | } 200 | 201 | #mocha-report.pending .test.pass, 202 | #mocha-report.pending .test.fail { 203 | display: none; 204 | } 205 | #mocha-report.pending .test.pass.pending { 206 | display: block; 207 | } 208 | 209 | #mocha-error { 210 | color: #c00; 211 | font-size: 1.5em; 212 | font-weight: 100; 213 | letter-spacing: 1px; 214 | } 215 | 216 | #mocha-stats { 217 | position: fixed; 218 | top: 15px; 219 | right: 10px; 220 | font-size: 12px; 221 | margin: 0; 222 | color: #888; 223 | z-index: 1; 224 | } 225 | 226 | #mocha-stats .progress { 227 | float: right; 228 | padding-top: 0; 229 | } 230 | 231 | #mocha-stats em { 232 | color: black; 233 | } 234 | 235 | #mocha-stats a { 236 | text-decoration: none; 237 | color: inherit; 238 | } 239 | 240 | #mocha-stats a:hover { 241 | border-bottom: 1px solid #eee; 242 | } 243 | 244 | #mocha-stats li { 245 | display: inline-block; 246 | margin: 0 5px; 247 | list-style: none; 248 | padding-top: 11px; 249 | } 250 | 251 | #mocha-stats canvas { 252 | width: 40px; 253 | height: 40px; 254 | } 255 | 256 | #mocha code .comment { color: #ddd; } 257 | #mocha code .init { color: #2f6fad; } 258 | #mocha code .string { color: #5890ad; } 259 | #mocha code .keyword { color: #8a6343; } 260 | #mocha code .number { color: #2f6fad; } 261 | 262 | @media screen and (max-device-width: 480px) { 263 | #mocha { 264 | margin: 60px 0px; 265 | } 266 | 267 | #mocha #stats { 268 | position: absolute; 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /test/base-cookie.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import cookie from '../src/base-cookie'; 3 | import base64Id from '../src/base64Id'; 4 | import Constants from '../src/constants'; 5 | import utils from '../src/utils'; 6 | import { mockCookie, restoreCookie, getCookie } from './mock-cookie'; 7 | 8 | describe('cookie', function () { 9 | afterEach(() => { 10 | restoreCookie(); 11 | }); 12 | 13 | describe('set', () => { 14 | it('should always set the path to /', () => { 15 | mockCookie(); 16 | cookie.set('key', 'val', {}); 17 | assert.include(getCookie('key').options, 'path=/'); 18 | }); 19 | 20 | it('should set the secure flag with the secure option', () => { 21 | mockCookie(); 22 | cookie.set('key', 'val', { secure: true }); 23 | assert.include(getCookie('key').options, 'Secure'); 24 | }); 25 | 26 | it('should set the same site value with the sameSite option', () => { 27 | mockCookie(); 28 | cookie.set('key', 'val', { sameSite: 'Lax' }); 29 | assert.include(getCookie('key').options, 'SameSite=Lax'); 30 | }); 31 | 32 | it('should set the expires option based on expirationDays', () => { 33 | mockCookie(); 34 | const clock = sinon.useFakeTimers(); 35 | cookie.set('key', 'val', { expirationDays: 54 }); 36 | assert.include(getCookie('key').options, 'expires=Tue, 24 Feb 1970 00:00:00 GMT'); 37 | clock.restore(); 38 | }); 39 | }); 40 | 41 | describe('get', () => { 42 | it('should retrieve a cookie that has been set', () => { 43 | cookie.set('key', 'val', {}); 44 | assert.equal(cookie.get('key='), 'val'); 45 | cookie.set('key', null, {}); 46 | }); 47 | 48 | it('should return null when attempting to retrieve a cookie that does not exist', () => { 49 | assert.isNull(cookie.get('key=')); 50 | }); 51 | }); 52 | 53 | describe('areCookiesEnabled', () => { 54 | before(() => { 55 | sinon.stub(Math, 'random').returns(1); 56 | }); 57 | after(() => { 58 | sinon.restore(); 59 | }); 60 | afterEach(() => { 61 | restoreCookie(); 62 | sinon.restore(); 63 | }); 64 | 65 | describe('when it can write to a cookie', () => { 66 | it('should return true', () => { 67 | assert.isTrue(cookie.areCookiesEnabled()); 68 | }); 69 | 70 | it('should cleanup cookies', () => { 71 | const cookieName = Constants.COOKIE_TEST_PREFIX + base64Id(); 72 | cookie.areCookiesEnabled(); 73 | assert.isNull(cookie.get(`${cookieName}=`), null); 74 | }); 75 | }); 76 | 77 | describe('when it cannot write to a cookie', () => { 78 | beforeEach(() => { 79 | mockCookie({ disabled: true }); 80 | }); 81 | 82 | it('should return false', () => { 83 | assert.isFalse(cookie.areCookiesEnabled()); 84 | }); 85 | 86 | it('should cleanup cookies', () => { 87 | const cookieName = Constants.COOKIE_TEST_PREFIX + base64Id(); 88 | 89 | cookie.areCookiesEnabled(); 90 | assert.isNull(cookie.get(`${cookieName}=`)); 91 | }); 92 | }); 93 | 94 | describe('when error is thrown during check', () => { 95 | it('should cleanup cookies', () => { 96 | const stubLogInfo = sinon.stub(utils.log, 'info').onFirstCall().throws('Stubbed Exception'); 97 | const spyLogWarning = sinon.spy(utils.log, 'warn'); 98 | const cookieName = Constants.COOKIE_TEST_PREFIX + base64Id(); 99 | const res = cookie.areCookiesEnabled(); 100 | assert.isFalse(res); 101 | assert.isTrue(spyLogWarning.calledWith('Error thrown when checking for cookies. Reason: "Stubbed Exception"')); 102 | assert.isNull(cookie.get(`${cookieName}=`)); 103 | 104 | stubLogInfo.restore(); 105 | spyLogWarning.restore(); 106 | }); 107 | }); 108 | 109 | describe('getLastEventTime tests', () => { 110 | it('should return 0 if cookie is undefined', () => { 111 | const cookieStr = undefined; 112 | const lastEventTime = cookie.getLastEventTime(cookieStr); 113 | assert.equal(lastEventTime, 0); 114 | }); 115 | 116 | it('should return 0 if cookie is an empty string', () => { 117 | const cookieStr = ''; 118 | const lastEventTime = cookie.getLastEventTime(cookieStr); 119 | assert.equal(lastEventTime, 0); 120 | }); 121 | 122 | it('should return 0 if cookie is a malformed cookie', () => { 123 | const cookieStr = 'asdfasdfasdfasdf'; 124 | const lastEventTime = cookie.getLastEventTime(cookieStr); 125 | assert.equal(lastEventTime, 0); 126 | }); 127 | 128 | it('should return a number thats base 32 encoded and put into the amplitude cookie format', () => { 129 | const originalTime = 1620698180822; 130 | const cookieStr = `....${originalTime.toString(32)}...`; 131 | const lastEventTime = cookie.getLastEventTime(cookieStr); 132 | assert.equal(lastEventTime, originalTime); 133 | }); 134 | }); 135 | 136 | describe('sortByEventTime tests', () => { 137 | it('should sort cookies by last event time from greatest to least', () => { 138 | const firstTime = 10; 139 | const secondTime = 20; 140 | const thirdTime = 30; 141 | const invalidTime = ''; 142 | 143 | const cookieArray = [secondTime, invalidTime, thirdTime, firstTime].map((t) => `....${t.toString(32)}...`); 144 | const sortedCookieArray = cookie.sortByEventTime(cookieArray); 145 | 146 | assert.notEqual(cookieArray, sortedCookieArray); // returns a shallow copy, not the same array 147 | assert.equal(sortedCookieArray[0], cookieArray[2]); // third time 148 | assert.equal(sortedCookieArray[1], cookieArray[0]); // second time 149 | assert.equal(sortedCookieArray[2], cookieArray[3]); // first time 150 | assert.equal(sortedCookieArray[3], cookieArray[1]); // invalid time 151 | }); 152 | }); 153 | }); 154 | }); 155 | -------------------------------------------------------------------------------- /src/revenue.js: -------------------------------------------------------------------------------- 1 | import constants from './constants'; 2 | import type from './type'; 3 | import utils from './utils'; 4 | 5 | /** 6 | * Revenue API - instance constructor. Wrapper for logging Revenue data. Revenue objects get passed to amplitude.logRevenueV2 to send to Amplitude servers. 7 | * Each method updates a revenue property in the Revenue object, and returns the same Revenue object, 8 | * allowing you to chain multiple method calls together. 9 | * 10 | * Note: price is a required field to log revenue events. 11 | * If quantity is not specified then defaults to 1. 12 | * @constructor Revenue 13 | * @public 14 | * @example var revenue = new amplitude.Revenue(); 15 | */ 16 | var Revenue = function Revenue() { 17 | // required fields 18 | this._price = null; 19 | 20 | // optional fields 21 | this._productId = null; 22 | this._quantity = 1; 23 | this._revenueType = null; 24 | this._properties = null; 25 | }; 26 | 27 | /** 28 | * Set a value for the product identifer. 29 | * @public 30 | * @param {string} productId - The value for the product identifier. Empty and invalid strings are ignored. 31 | * @return {Revenue} Returns the same Revenue object, allowing you to chain multiple method calls together. 32 | * @example var revenue = new amplitude.Revenue().setProductId('productIdentifier').setPrice(10.99); 33 | * amplitude.logRevenueV2(revenue); 34 | */ 35 | Revenue.prototype.setProductId = function setProductId(productId) { 36 | if (type(productId) !== 'string') { 37 | utils.log.error('Unsupported type for productId: ' + type(productId) + ', expecting string'); 38 | } else if (utils.isEmptyString(productId)) { 39 | utils.log.error('Invalid empty productId'); 40 | } else { 41 | this._productId = productId; 42 | } 43 | return this; 44 | }; 45 | 46 | /** 47 | * Set a value for the quantity. Note revenue amount is calculated as price * quantity. 48 | * @public 49 | * @param {number} quantity - Integer value for the quantity. If not set, quantity defaults to 1. 50 | * @return {Revenue} Returns the same Revenue object, allowing you to chain multiple method calls together. 51 | * @example var revenue = new amplitude.Revenue().setProductId('productIdentifier').setPrice(10.99).setQuantity(5); 52 | * amplitude.logRevenueV2(revenue); 53 | */ 54 | Revenue.prototype.setQuantity = function setQuantity(quantity) { 55 | if (type(quantity) !== 'number') { 56 | utils.log.error('Unsupported type for quantity: ' + type(quantity) + ', expecting number'); 57 | } else { 58 | this._quantity = parseInt(quantity); 59 | } 60 | return this; 61 | }; 62 | 63 | /** 64 | * Set a value for the price. This field is required for all revenue being logged. 65 | * 66 | * Note: revenue amount is calculated as price * quantity. 67 | * @public 68 | * @param {number} price - Double value for the quantity. 69 | * @return {Revenue} Returns the same Revenue object, allowing you to chain multiple method calls together. 70 | * @example var revenue = new amplitude.Revenue().setProductId('productIdentifier').setPrice(10.99); 71 | * amplitude.logRevenueV2(revenue); 72 | */ 73 | Revenue.prototype.setPrice = function setPrice(price) { 74 | if (type(price) !== 'number') { 75 | utils.log.error('Unsupported type for price: ' + type(price) + ', expecting number'); 76 | } else { 77 | this._price = price; 78 | } 79 | return this; 80 | }; 81 | 82 | /** 83 | * Set a value for the revenueType (for example purchase, cost, tax, refund, etc). 84 | * @public 85 | * @param {string} revenueType - RevenueType to designate. 86 | * @return {Revenue} Returns the same Revenue object, allowing you to chain multiple method calls together. 87 | * @example var revenue = new amplitude.Revenue().setProductId('productIdentifier').setPrice(10.99).setRevenueType('purchase'); 88 | * amplitude.logRevenueV2(revenue); 89 | */ 90 | Revenue.prototype.setRevenueType = function setRevenueType(revenueType) { 91 | if (type(revenueType) !== 'string') { 92 | utils.log.error('Unsupported type for revenueType: ' + type(revenueType) + ', expecting string'); 93 | } else { 94 | this._revenueType = revenueType; 95 | } 96 | return this; 97 | }; 98 | 99 | /** 100 | * Set event properties for the revenue event. 101 | * @public 102 | * @param {object} eventProperties - Revenue event properties to set. 103 | * @return {Revenue} Returns the same Revenue object, allowing you to chain multiple method calls together. 104 | * @example var event_properties = {'city': 'San Francisco'}; 105 | * var revenue = new amplitude.Revenue().setProductId('productIdentifier').setPrice(10.99).setEventProperties(event_properties); 106 | * amplitude.logRevenueV2(revenue); 107 | */ 108 | Revenue.prototype.setEventProperties = function setEventProperties(eventProperties) { 109 | if (type(eventProperties) !== 'object') { 110 | utils.log.error('Unsupported type for eventProperties: ' + type(eventProperties) + ', expecting object'); 111 | } else { 112 | this._properties = utils.validateProperties(eventProperties); 113 | } 114 | return this; 115 | }; 116 | 117 | /** 118 | * @private 119 | */ 120 | Revenue.prototype._isValidRevenue = function _isValidRevenue() { 121 | if (type(this._price) !== 'number') { 122 | utils.log.error('Invalid revenue, need to set price field'); 123 | return false; 124 | } 125 | return true; 126 | }; 127 | 128 | /** 129 | * @private 130 | */ 131 | Revenue.prototype._toJSONObject = function _toJSONObject() { 132 | var obj = type(this._properties) === 'object' ? this._properties : {}; 133 | 134 | if (this._productId !== null) { 135 | obj[constants.REVENUE_PRODUCT_ID] = this._productId; 136 | } 137 | if (this._quantity !== null) { 138 | obj[constants.REVENUE_QUANTITY] = this._quantity; 139 | } 140 | if (this._price !== null) { 141 | obj[constants.REVENUE_PRICE] = this._price; 142 | } 143 | if (this._revenueType !== null) { 144 | obj[constants.REVENUE_REVENUE_TYPE] = this._revenueType; 145 | } 146 | return obj; 147 | }; 148 | 149 | export default Revenue; 150 | -------------------------------------------------------------------------------- /src/metadata-storage.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Persist SDK event metadata 3 | * Uses cookie if available, otherwise fallback to localstorage. 4 | */ 5 | 6 | import Base64 from './base64'; 7 | import baseCookie from './base-cookie'; 8 | import Constants from './constants'; 9 | import ampLocalStorage from './localstorage'; 10 | import topDomain from './top-domain'; 11 | import utils from './utils'; 12 | import GlobalScope from './global-scope'; 13 | 14 | const storageOptionExists = { 15 | [Constants.STORAGE_COOKIES]: true, 16 | [Constants.STORAGE_NONE]: true, 17 | [Constants.STORAGE_LOCAL]: true, 18 | [Constants.STORAGE_SESSION]: true, 19 | }; 20 | 21 | /** 22 | * MetadataStorage involves SDK data persistance 23 | * storage priority: cookies -> localStorage -> in memory 24 | * This priority can be overriden by setting the storage options. 25 | * if in localStorage, unable track users between subdomains 26 | * if in memory, then memory can't be shared between different tabs 27 | */ 28 | class MetadataStorage { 29 | constructor({ storageKey, disableCookies, domain, secure, sameSite, expirationDays, storage }) { 30 | this.storageKey = storageKey; 31 | this.domain = domain; 32 | this.secure = secure; 33 | this.sameSite = sameSite; 34 | this.expirationDays = expirationDays; 35 | 36 | this.cookieDomain = ''; 37 | const loc = utils.getLocation() ? utils.getLocation().href : undefined; 38 | const writableTopDomain = !disableCookies ? topDomain(loc) : ''; 39 | this.cookieDomain = domain || (writableTopDomain ? '.' + writableTopDomain : null); 40 | 41 | if (storageOptionExists[storage]) { 42 | this.storage = storage; 43 | } else { 44 | const disableCookieStorage = 45 | disableCookies || 46 | !baseCookie.areCookiesEnabled({ 47 | domain: this.cookieDomain, 48 | secure: this.secure, 49 | sameSite: this.sameSite, 50 | expirationDays: this.expirationDays, 51 | }); 52 | if (disableCookieStorage) { 53 | this.storage = Constants.STORAGE_LOCAL; 54 | } else { 55 | this.storage = Constants.STORAGE_COOKIES; 56 | } 57 | } 58 | } 59 | 60 | getCookieStorageKey() { 61 | if (!this.domain) { 62 | return this.storageKey; 63 | } 64 | 65 | const suffix = this.domain.charAt(0) === '.' ? this.domain.substring(1) : this.domain; 66 | 67 | return `${this.storageKey}${suffix ? `_${suffix}` : ''}`; 68 | } 69 | 70 | /* 71 | * Data is saved as delimited values rather than JSO to minimize cookie space 72 | * Should not change order of the items 73 | */ 74 | save({ deviceId, userId, optOut, sessionId, lastEventTime, eventId, identifyId, sequenceNumber }) { 75 | if (this.storage === Constants.STORAGE_NONE) { 76 | return; 77 | } 78 | const value = [ 79 | deviceId, 80 | Base64.encode(userId || ''), // used to convert not unicode to alphanumeric since cookies only use alphanumeric 81 | optOut ? '1' : '', 82 | sessionId ? sessionId.toString(32) : '0', // generated when instantiated, timestamp (but re-uses session id in cookie if not expired) @TODO clients may want custom session id 83 | lastEventTime ? lastEventTime.toString(32) : '0', // last time an event was set 84 | eventId ? eventId.toString(32) : '0', 85 | identifyId ? identifyId.toString(32) : '0', 86 | sequenceNumber ? sequenceNumber.toString(32) : '0', 87 | ].join('.'); 88 | 89 | switch (this.storage) { 90 | case Constants.STORAGE_SESSION: 91 | if (GlobalScope.sessionStorage) { 92 | GlobalScope.sessionStorage.setItem(this.storageKey, value); 93 | } 94 | break; 95 | case Constants.STORAGE_LOCAL: 96 | ampLocalStorage.setItem(this.storageKey, value); 97 | break; 98 | case Constants.STORAGE_COOKIES: 99 | this.saveCookie(value); 100 | break; 101 | } 102 | } 103 | 104 | saveCookie(value) { 105 | baseCookie.set(this.getCookieStorageKey(), value, { 106 | domain: this.cookieDomain, 107 | secure: this.secure, 108 | sameSite: this.sameSite, 109 | expirationDays: this.expirationDays, 110 | }); 111 | } 112 | 113 | load() { 114 | let str; 115 | if (this.storage === Constants.STORAGE_COOKIES) { 116 | const cookieKey = this.getCookieStorageKey() + '='; 117 | const allCookies = baseCookie.getAll(cookieKey); 118 | if (allCookies.length === 0 || allCookies.length === 1) { 119 | str = allCookies[0]; 120 | } else { 121 | // dedup cookies by deleting them all and restoring 122 | // the one with the most recent event time 123 | const latestCookie = baseCookie.sortByEventTime(allCookies)[0]; 124 | allCookies.forEach(() => baseCookie.set(this.getCookieStorageKey(), null, {})); 125 | this.saveCookie(latestCookie); 126 | str = baseCookie.get(cookieKey); 127 | } 128 | } 129 | if (!str) { 130 | str = ampLocalStorage.getItem(this.storageKey); 131 | } 132 | if (!str) { 133 | try { 134 | str = GlobalScope.sessionStorage && GlobalScope.sessionStorage.getItem(this.storageKey); 135 | } catch (e) { 136 | utils.log.info(`window.sessionStorage unavailable. Reason: "${e}"`); 137 | } 138 | } 139 | 140 | if (!str) { 141 | return null; 142 | } 143 | 144 | const values = str.split('.'); 145 | 146 | let userId = null; 147 | if (values[Constants.USER_ID_INDEX]) { 148 | try { 149 | userId = Base64.decode(values[Constants.USER_ID_INDEX]); 150 | } catch (e) { 151 | userId = null; 152 | } 153 | } 154 | 155 | return { 156 | deviceId: values[Constants.DEVICE_ID_INDEX], 157 | userId, 158 | optOut: values[Constants.OPT_OUT_INDEX] === '1', 159 | sessionId: parseInt(values[Constants.SESSION_ID_INDEX], 32), 160 | lastEventTime: parseInt(values[Constants.LAST_EVENT_TIME_INDEX], 32), 161 | eventId: parseInt(values[Constants.EVENT_ID_INDEX], 32), 162 | identifyId: parseInt(values[Constants.IDENTIFY_ID_INDEX], 32), 163 | sequenceNumber: parseInt(values[Constants.SEQUENCE_NUMBER_INDEX], 32), 164 | }; 165 | } 166 | 167 | /** 168 | * Clears any saved metadata storage 169 | * @constructor AmplitudeClient 170 | * @public 171 | * @return {boolean} True if metadata was cleared, false if none existed 172 | */ 173 | clear() { 174 | let str; 175 | if (this.storage === Constants.STORAGE_COOKIES) { 176 | str = baseCookie.get(this.getCookieStorageKey() + '='); 177 | baseCookie.set(this.getCookieStorageKey(), null, { 178 | domain: this.cookieDomain, 179 | secure: this.secure, 180 | sameSite: this.sameSite, 181 | expirationDays: 0, 182 | }); 183 | } 184 | if (!str) { 185 | str = ampLocalStorage.getItem(this.storageKey); 186 | ampLocalStorage.clear(); 187 | } 188 | if (!str) { 189 | try { 190 | str = GlobalScope.sessionStorage && GlobalScope.sessionStorage.getItem(this.storageKey); 191 | GlobalScope.sessionStorage.clear(); 192 | } catch (e) { 193 | utils.log.info(`window.sessionStorage unavailable. Reason: "${e}"`); 194 | } 195 | } 196 | return !!str; 197 | } 198 | } 199 | 200 | export default MetadataStorage; 201 | -------------------------------------------------------------------------------- /test/browser/amplitudejs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 91 | 113 | 114 |

        Amplitude JS Test

        115 |
          116 |
        • Set user ID
        • 117 |
        • Toggle opt out
        • 118 |
        • Log event
        • 119 |
        • Log 120 | event with event properties
        • 121 |
        • Set user properties
        • 122 |
        • Toggle batch events
        • 123 |
        • Set event upload threshold
        • 124 |
        • Click on link A
        • 125 |

          Testing Identify calls
          126 |
        • Add to photo count
        • 127 |
        • Unset photo count
        • 128 |
        • Set photo count
        • 129 |
        • Set photo count once
        • 130 |
        • Set city via setUserProperties
        • 131 |
        • Clear all user properties
        • 132 |
        • LogRevenueV2
        • 133 |

          134 |
        • Go to second page
        • 135 | 136 | 137 | -------------------------------------------------------------------------------- /test/web-worker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-undef */ 2 | importScripts('/base/amplitude.js'); 3 | importScripts('/base/node_modules/sinon/pkg/sinon.js'); 4 | const { createSandbox } = sinon; 5 | /* eslint-enable no-undef */ 6 | 7 | var isTrue = function (a) { 8 | if (!a) { 9 | throw new Error('Assertion failed: object is falsey.'); 10 | } 11 | }; 12 | 13 | describe('web worker', function () { 14 | let sbox; 15 | beforeEach(function () { 16 | sbox = createSandbox(); 17 | }); 18 | 19 | afterEach(function () { 20 | sbox.restore(); 21 | }); 22 | 23 | describe('init', () => { 24 | it('should init successfully', () => { 25 | const onSuccess = sbox.spy(); 26 | const onError = sbox.spy(); 27 | amplitude.init( 28 | 'API_KEY', 29 | undefined, 30 | { 31 | onError: onError, 32 | eventUploadThreshold: 1, 33 | }, 34 | onSuccess, 35 | ); 36 | isTrue(amplitude.getInstance()._isInitialized); 37 | isTrue(amplitude.getInstance()._newSession); 38 | isTrue(onSuccess.calledOnce); 39 | isTrue(onError.notCalled); 40 | }); 41 | }); 42 | 43 | describe('logEvent', () => { 44 | it('should log event successfully', () => { 45 | const onError = sbox.spy(); 46 | const sendEvents = sbox.stub(amplitude.getInstance(), 'sendEvents').returns(undefined); 47 | amplitude 48 | .getInstance() 49 | .logEvent('event', {}, undefined, undefined, undefined, undefined, undefined, undefined, onError); 50 | isTrue(sendEvents.calledOnce); 51 | isTrue(onError.notCalled); 52 | }); 53 | }); 54 | 55 | describe('logEventWithGroups', () => { 56 | it('should log event with groups successfully', () => { 57 | const callback = sbox.spy(); 58 | const onError = sbox.spy(); 59 | const outOfSession = false; 60 | const sendEvents = sbox.stub(amplitude.getInstance(), 'sendEvents').returns(undefined); 61 | amplitude.getInstance().logEventWithGroups('event', {}, undefined, callback, onError, outOfSession); 62 | isTrue(sendEvents.calledOnce); 63 | isTrue(onError.notCalled); 64 | }); 65 | }); 66 | 67 | describe('sendEvents', () => { 68 | it('should send event successfully', () => { 69 | const _unsentCount = sbox.stub(amplitude.getInstance(), '_unsentCount').returns(1); 70 | const _mergeEventsAndIdentifys = sbox.stub(amplitude.getInstance(), '_mergeEventsAndIdentifys').returns({ 71 | eventsToSend: [{ event: {} }], 72 | }); 73 | const _logErrorsOnEvents = sbox.stub(amplitude.getInstance(), '_logErrorsOnEvents').returns(); 74 | amplitude.getInstance().sendEvents(); 75 | isTrue(_unsentCount.callCount === 2); 76 | isTrue(_mergeEventsAndIdentifys.calledOnce); 77 | isTrue(_logErrorsOnEvents.notCalled); 78 | }); 79 | }); 80 | 81 | describe('identify', () => { 82 | it('should identify successfully', () => { 83 | const callback = sbox.spy(); 84 | const errorCallback = sbox.spy(); 85 | const outOfSession = false; 86 | const sendEvents = sbox.stub(amplitude.getInstance(), 'sendEvents').returns(undefined); 87 | const identity = new amplitude.Identify().set('colors', ['rose', 'gold']); 88 | amplitude.getInstance().identify(identity, callback, errorCallback, outOfSession); 89 | isTrue(sendEvents.calledOnce); 90 | isTrue(errorCallback.notCalled); 91 | }); 92 | }); 93 | 94 | describe('groupIdentify', () => { 95 | it('should group identify successfully', () => { 96 | const callback = sbox.spy(); 97 | const errorCallback = sbox.spy(); 98 | const outOfSession = false; 99 | const sendEvents = sbox.stub(amplitude.getInstance(), 'sendEvents').returns(undefined); 100 | const identity = new amplitude.Identify().set('colors', ['rose', 'gold']); 101 | amplitude.getInstance().groupIdentify('groupType', 'groupName', identity, callback, errorCallback, outOfSession); 102 | isTrue(sendEvents.calledOnce); 103 | isTrue(errorCallback.notCalled); 104 | }); 105 | }); 106 | 107 | describe('logRevenue', () => { 108 | it('should log revenue successfully', () => { 109 | const _logEvent = sbox.stub(amplitude.getInstance(), '_logEvent').returns(undefined); 110 | amplitude.logRevenue(1, 1, 'asdf'); 111 | isTrue(_logEvent.calledOnce); 112 | }); 113 | }); 114 | 115 | describe('logRevenueV2', () => { 116 | it('should log revenue successfully', () => { 117 | const revenue = new amplitude.Revenue().setProductId('productIdentifier').setPrice(10.99); 118 | const sendEvents = sbox.stub(amplitude.getInstance(), 'sendEvents').returns(undefined); 119 | amplitude.logRevenueV2(revenue); 120 | isTrue(sendEvents.calledOnce); 121 | }); 122 | }); 123 | 124 | describe('setGroup', () => { 125 | it('should set group successfully', () => { 126 | const onError = sbox.spy(); 127 | const sendEvents = sbox.stub(amplitude.getInstance(), 'sendEvents').returns(undefined); 128 | amplitude.getInstance().setGroup('groupType', 'groupName'); 129 | isTrue(sendEvents.calledOnce); 130 | isTrue(onError.notCalled); 131 | }); 132 | }); 133 | 134 | describe('setUserProperties', () => { 135 | it('should set user properties successfully', () => { 136 | const identify = sbox.stub(amplitude.getInstance(), 'identify').returns(undefined); 137 | amplitude.getInstance().setUserProperties({ a: 1 }); 138 | isTrue(identify.calledOnce); 139 | }); 140 | }); 141 | 142 | describe('setVersionName', () => { 143 | it('should set version name successfully', () => { 144 | amplitude.getInstance().setVersionName('1.1.1'); 145 | isTrue(amplitude.getInstance().options.versionName === '1.1.1'); 146 | }); 147 | }); 148 | 149 | describe('enableTracking', () => { 150 | it('should set domain successfully', () => { 151 | const runQueuedFunctions = sbox.stub(amplitude.getInstance(), 'runQueuedFunctions').returns(undefined); 152 | amplitude.getInstance().enableTracking(); 153 | isTrue(runQueuedFunctions.calledOnce); 154 | }); 155 | }); 156 | 157 | describe('setGlobalUserProperties', () => { 158 | it('should set global user properties successfully', () => { 159 | const setUserProperties = sbox.stub(amplitude.getInstance(), 'setUserProperties').returns(undefined); 160 | amplitude.getInstance().setGlobalUserProperties(); 161 | isTrue(setUserProperties.calledOnce); 162 | }); 163 | }); 164 | 165 | describe('clearUserProperties', () => { 166 | it('should call set user properties successfully', () => { 167 | const identify = sbox.stub(amplitude.getInstance(), 'identify').returns(undefined); 168 | amplitude.getInstance().clearUserProperties(); 169 | isTrue(identify.calledOnce); 170 | }); 171 | }); 172 | 173 | describe('regenerateDeviceId', () => { 174 | it('should regenerate device id successfully', () => { 175 | const setDeviceId = sbox.stub(amplitude.getInstance(), 'setDeviceId').returns(undefined); 176 | amplitude.getInstance().regenerateDeviceId(); 177 | isTrue(setDeviceId.calledOnce); 178 | }); 179 | }); 180 | 181 | describe('setSessionId', () => { 182 | it('should set new session id successfully', () => { 183 | amplitude.getInstance().setSessionId(123); 184 | isTrue(amplitude.getInstance().getSessionId() === 123); 185 | }); 186 | }); 187 | 188 | describe('resetSessionId', () => { 189 | it('should set new session id successfully', () => { 190 | amplitude.getInstance().resetSessionId(); 191 | isTrue(amplitude.getInstance().getSessionId() !== 123); 192 | }); 193 | }); 194 | 195 | describe('setUseDynamicConfig', () => { 196 | it('should set use dynamic config successfully', () => { 197 | const _refreshDynamicConfig = sbox.stub(amplitude.getInstance(), '_refreshDynamicConfig').returns(undefined); 198 | amplitude.getInstance().setUseDynamicConfig(true); 199 | isTrue(_refreshDynamicConfig.calledOnce); 200 | }); 201 | }); 202 | }); 203 | -------------------------------------------------------------------------------- /test/utils.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import utils from '../src/utils.js'; 3 | import constants from '../src/constants.js'; 4 | import GlobalScope from '../src/global-scope'; 5 | 6 | describe('utils', function () { 7 | describe('isEmptyString', function () { 8 | it('should detect empty strings', function () { 9 | assert.isTrue(utils.isEmptyString(null)); 10 | assert.isTrue(utils.isEmptyString('')); 11 | assert.isTrue(utils.isEmptyString(undefined)); 12 | assert.isTrue(utils.isEmptyString(NaN)); 13 | assert.isFalse(utils.isEmptyString(' ')); 14 | assert.isFalse(utils.isEmptyString('string')); 15 | assert.isFalse(utils.isEmptyString('string')); 16 | }); 17 | }); 18 | 19 | describe('setLogLevel', function () { 20 | afterEach(() => { 21 | utils.setLogLevel('WARN'); 22 | }); 23 | 24 | it('can set log level to DISABLE', function () { 25 | utils.setLogLevel('DISABLE'); 26 | assert.strictEqual(utils.getLogLevel(), utils.logLevels.DISABLE); 27 | }); 28 | 29 | it('can set log level to ERROR', () => { 30 | utils.setLogLevel('ERROR'); 31 | assert.strictEqual(utils.getLogLevel(), utils.logLevels.ERROR); 32 | }); 33 | 34 | it('can set log level to WARN', () => { 35 | utils.setLogLevel('DISABLE'); 36 | utils.setLogLevel('WARN'); 37 | assert.strictEqual(utils.getLogLevel(), utils.logLevels.WARN); 38 | }); 39 | 40 | it('can set log level to INFO', () => { 41 | utils.setLogLevel('INFO'); 42 | assert.strictEqual(utils.getLogLevel(), utils.logLevels.INFO); 43 | }); 44 | }); 45 | 46 | describe('log', function () { 47 | beforeEach(function () { 48 | utils.setLogLevel('INFO'); 49 | sinon.spy(console, 'log'); 50 | }); 51 | 52 | afterEach(function () { 53 | console.log.restore(); 54 | }); 55 | 56 | describe('setLogLevelShould ignore invalid log levels', function () { 57 | utils.setLogLevel('INVALID_LOGLEVEL'); 58 | assert.strictEqual(utils.getLogLevel(), 2); 59 | }); 60 | 61 | describe('logLevel is ERROR', function () { 62 | beforeEach(function () { 63 | utils.setLogLevel('ERROR'); 64 | }); 65 | 66 | it('should not log warnings', function () { 67 | utils.log.warn('warning'); 68 | assert.isFalse(console.log.called); 69 | }); 70 | 71 | it('should not log info', function () { 72 | utils.log.info('info'); 73 | assert.isFalse(console.log.called); 74 | }); 75 | 76 | it('should log errors', function () { 77 | utils.log.error('error'); 78 | assert.isTrue(console.log.calledOnce); 79 | }); 80 | }); 81 | 82 | describe('logLevel is WARN', function () { 83 | beforeEach(function () { 84 | utils.setLogLevel('WARN'); 85 | }); 86 | 87 | it('should log warnings', function () { 88 | utils.log.warn('warning'); 89 | assert.isTrue(console.log.calledOnce); 90 | }); 91 | 92 | it('should log errors', function () { 93 | utils.log.error('errors'); 94 | assert.isTrue(console.log.calledOnce); 95 | }); 96 | 97 | it('should not log info', function () { 98 | utils.log.info('info'); 99 | assert.isFalse(console.log.called); 100 | }); 101 | }); 102 | 103 | describe('logLevel is INFO', function () { 104 | beforeEach(function () { 105 | utils.setLogLevel('INFO'); 106 | }); 107 | 108 | it('should log errors', function () { 109 | utils.log.error('error'); 110 | assert.isTrue(console.log.calledOnce); 111 | }); 112 | 113 | it('should log warnings', function () { 114 | utils.log.warn('warn'); 115 | assert.isTrue(console.log.calledOnce); 116 | }); 117 | 118 | it('should log info', function () { 119 | utils.log.info('info'); 120 | assert.isTrue(console.log.calledOnce); 121 | }); 122 | }); 123 | }); 124 | 125 | describe('validateProperties', function () { 126 | it('should detect invalid event property formats', function () { 127 | assert.deepEqual({}, utils.validateProperties('string')); 128 | assert.deepEqual({}, utils.validateProperties(null)); 129 | assert.deepEqual({}, utils.validateProperties(undefined)); 130 | assert.deepEqual({}, utils.validateProperties(10)); 131 | assert.deepEqual({}, utils.validateProperties(true)); 132 | assert.deepEqual({}, utils.validateProperties(new Date())); 133 | assert.deepEqual({}, utils.validateProperties([])); 134 | assert.deepEqual({}, utils.validateProperties(NaN)); 135 | }); 136 | 137 | it('should not modify valid event property formats', function () { 138 | var properties = { 139 | test: 'yes', 140 | key: 'value', 141 | 15: '16', 142 | }; 143 | assert.deepEqual(properties, utils.validateProperties(properties)); 144 | }); 145 | 146 | it('should coerce non-string keys', function () { 147 | var d = new Date(); 148 | var dateString = String(d); 149 | 150 | var properties = { 151 | 10: 'false', 152 | null: 'value', 153 | NaN: '16', 154 | d: dateString, 155 | }; 156 | var expected = { 157 | 10: 'false', 158 | null: 'value', 159 | NaN: '16', 160 | d: dateString, 161 | }; 162 | assert.deepEqual(utils.validateProperties(properties), expected); 163 | }); 164 | 165 | it('should ignore invalid event property values', function () { 166 | var properties = { 167 | null: null, 168 | undefined: undefined, 169 | NaN: NaN, 170 | function: utils.log.warn, 171 | }; 172 | assert.deepEqual({}, utils.validateProperties(properties)); 173 | }); 174 | 175 | it('should coerce error values', function () { 176 | var e = new Error('oops'); 177 | 178 | var properties = { 179 | error: e, 180 | }; 181 | var expected = { 182 | error: String(e), 183 | }; 184 | assert.deepEqual(utils.validateProperties(properties), expected); 185 | }); 186 | 187 | it('should validate properties', function () { 188 | var e = new Error('oops'); 189 | 190 | var properties = { 191 | 10: 'false', // coerce key 192 | bool: true, 193 | null: null, // should be ignored 194 | function: console.log, // should be ignored 195 | regex: /afdg/, // should be ignored 196 | error: e, // coerce value 197 | string: 'test', 198 | array: [0, 1, 2, '3'], 199 | nested_array: ['a', { key: 'value' }, ['b']], 200 | object: { 201 | key: 'value', 202 | 15: e, 203 | }, 204 | nested_object: { 205 | k: 'v', 206 | l: [0, 1], 207 | o: { 208 | k2: 'v2', 209 | l2: ['e2', { k3: 'v3' }], 210 | }, 211 | }, 212 | }; 213 | var expected = { 214 | 10: 'false', 215 | bool: true, 216 | error: 'Error: oops', 217 | string: 'test', 218 | array: [0, 1, 2, '3'], 219 | nested_array: ['a', { key: 'value' }], 220 | object: { 221 | key: 'value', 222 | 15: 'Error: oops', 223 | }, 224 | nested_object: { 225 | k: 'v', 226 | l: [0, 1], 227 | o: { 228 | k2: 'v2', 229 | l2: ['e2', { k3: 'v3' }], 230 | }, 231 | }, 232 | }; 233 | assert.deepEqual(utils.validateProperties(properties), expected); 234 | }); 235 | 236 | it('should block properties with too many items', function () { 237 | var properties = {}; 238 | for (var i = 0; i < constants.MAX_PROPERTY_KEYS + 1; i++) { 239 | properties[i] = i; 240 | } 241 | assert.deepEqual(utils.validateProperties(properties), {}); 242 | }); 243 | 244 | it('should validate properties on null objects', function () { 245 | var properties = Object.create(null); 246 | properties['test'] = 'yes'; 247 | properties['key'] = 'value'; 248 | properties['15'] = '16'; 249 | 250 | var expected = { 251 | test: 'yes', 252 | key: 'value', 253 | 15: '16', 254 | }; 255 | assert.deepEqual(utils.validateProperties(properties), expected); 256 | }); 257 | }); 258 | 259 | describe('isWebWorkerEnvironment', function () { 260 | it('should return false', function () { 261 | assert.isFalse(utils.isWebWorkerEnvironment()); 262 | }); 263 | }); 264 | 265 | describe('validateSessionId', function () { 266 | it('should return true', function () { 267 | assert.isTrue(utils.validateSessionId(Date.now())); 268 | }); 269 | 270 | it('should return false', function () { 271 | assert.isFalse(utils.validateSessionId('asdf')); 272 | assert.isFalse(utils.validateSessionId(0)); 273 | assert.isFalse(utils.validateSessionId(NaN)); 274 | assert.isFalse(utils.validateSessionId(null)); 275 | assert.isFalse(utils.validateSessionId(undefined)); 276 | assert.isFalse(utils.validateSessionId({})); 277 | assert.isFalse(utils.validateSessionId([])); 278 | assert.isFalse(utils.validateSessionId(new Map())); 279 | assert.isFalse(utils.validateSessionId(new Set())); 280 | assert.isFalse(utils.validateSessionId(true)); 281 | assert.isFalse(utils.validateSessionId(false)); 282 | }); 283 | }); 284 | 285 | describe('getHost', function () { 286 | it('should return hostname for url', function () { 287 | const url = 'https://amplitude.is.good.com/test'; 288 | assert.equal(utils.getHost(url), 'amplitude.is.good.com'); 289 | }); 290 | 291 | it('should return current hostname if no url is provided', function () { 292 | assert.equal(utils.getHost(), GlobalScope.location.hostname); 293 | }); 294 | }); 295 | 296 | describe('getLocation', function () { 297 | it('should return global location', function () { 298 | assert.equal(utils.getLocation(), GlobalScope.location); 299 | }); 300 | }); 301 | }); 302 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import constants from './constants'; 2 | import GlobalScope from './global-scope'; 3 | import type from './type'; 4 | 5 | var logLevels = { 6 | DISABLE: 0, 7 | ERROR: 1, 8 | WARN: 2, 9 | INFO: 3, 10 | }; 11 | 12 | let logLevel = logLevels.WARN; 13 | 14 | const setLogLevel = function setLogLevel(logLevelName) { 15 | if (Object.prototype.hasOwnProperty.call(logLevels, logLevelName)) { 16 | logLevel = logLevels[logLevelName]; 17 | } 18 | }; 19 | 20 | const getLogLevel = function getLogLevel() { 21 | return logLevel; 22 | }; 23 | 24 | const log = { 25 | error: (s) => { 26 | if (logLevel >= logLevels.ERROR) { 27 | _log(s); 28 | } 29 | }, 30 | 31 | warn: (s) => { 32 | if (logLevel >= logLevels.WARN) { 33 | _log(s); 34 | } 35 | }, 36 | 37 | info: (s) => { 38 | if (logLevel >= logLevels.INFO) { 39 | _log(s); 40 | } 41 | }, 42 | }; 43 | 44 | var _log = function _log(s) { 45 | try { 46 | console.log('[Amplitude] ' + s); 47 | } catch (e) { 48 | // console logging not available 49 | } 50 | }; 51 | 52 | var isEmptyString = function isEmptyString(str) { 53 | return !str || str.length === 0; 54 | }; 55 | 56 | var sessionStorageEnabled = function sessionStorageEnabled() { 57 | try { 58 | if (GlobalScope.sessionStorage) { 59 | return true; 60 | } 61 | } catch (e) { 62 | // sessionStorage disabled 63 | } 64 | return false; 65 | }; 66 | 67 | // truncate string values in event and user properties so that request size does not get too large 68 | var truncate = function truncate(value) { 69 | if (type(value) === 'array') { 70 | for (var i = 0; i < value.length; i++) { 71 | value[i] = truncate(value[i]); 72 | } 73 | } else if (type(value) === 'object') { 74 | for (var key in value) { 75 | if (key in value) { 76 | value[key] = truncate(value[key]); 77 | } 78 | } 79 | } else { 80 | value = _truncateValue(value); 81 | } 82 | 83 | return value; 84 | }; 85 | 86 | var _truncateValue = function _truncateValue(value) { 87 | if (type(value) === 'string') { 88 | return value.length > constants.MAX_STRING_LENGTH ? value.substring(0, constants.MAX_STRING_LENGTH) : value; 89 | } 90 | return value; 91 | }; 92 | 93 | var validateInput = function validateInput(input, name, expectedType) { 94 | if (type(input) !== expectedType) { 95 | log.error('Invalid ' + name + ' input type. Expected ' + expectedType + ' but received ' + type(input)); 96 | return false; 97 | } 98 | return true; 99 | }; 100 | 101 | const validateDeviceId = function validateDeviceId(deviceId) { 102 | if (!validateInput(deviceId, 'deviceId', 'string')) { 103 | return false; 104 | } 105 | if (deviceId.indexOf('.') >= 0) { 106 | log.error(`Device IDs may not contain '.' characters. Value will be ignored: "${deviceId}"`); 107 | return false; 108 | } 109 | return true; 110 | }; 111 | 112 | const validateTransport = function validateTransport(transport) { 113 | if (!validateInput(transport, 'transport', 'string')) { 114 | return false; 115 | } 116 | 117 | if (transport !== constants.TRANSPORT_HTTP && transport !== constants.TRANSPORT_BEACON) { 118 | log.error(`transport value must be one of '${constants.TRANSPORT_BEACON}' or '${constants.TRANSPORT_HTTP}'`); 119 | return false; 120 | } 121 | 122 | if (transport !== constants.TRANSPORT_HTTP && typeof navigator !== 'undefined' && !navigator.sendBeacon) { 123 | log.error(`browser does not support sendBeacon, so transport must be HTTP`); 124 | return false; 125 | } 126 | return true; 127 | }; 128 | 129 | // do some basic sanitization and type checking, also catch property dicts with more than 1000 key/value pairs 130 | var validateProperties = function validateProperties(properties) { 131 | var propsType = type(properties); 132 | if (propsType !== 'object') { 133 | log.error('Error: invalid properties format. Expecting Javascript object, received ' + propsType + ', ignoring'); 134 | return {}; 135 | } 136 | 137 | if (Object.keys(properties).length > constants.MAX_PROPERTY_KEYS) { 138 | log.error('Error: too many properties (more than 1000), ignoring'); 139 | return {}; 140 | } 141 | 142 | var copy = {}; // create a copy with all of the valid properties 143 | for (var property in properties) { 144 | if (!Object.prototype.hasOwnProperty.call(properties, property)) { 145 | continue; 146 | } 147 | 148 | // validate key 149 | var key = property; 150 | var keyType = type(key); 151 | if (keyType !== 'string') { 152 | key = String(key); 153 | log.warn('WARNING: Non-string property key, received type ' + keyType + ', coercing to string "' + key + '"'); 154 | } 155 | 156 | // validate value 157 | var value = validatePropertyValue(key, properties[property]); 158 | if (value === null) { 159 | continue; 160 | } 161 | copy[key] = value; 162 | } 163 | return copy; 164 | }; 165 | 166 | var invalidValueTypes = ['nan', 'function', 'arguments', 'regexp', 'element']; 167 | 168 | var validatePropertyValue = function validatePropertyValue(key, value) { 169 | var valueType = type(value); 170 | if (invalidValueTypes.indexOf(valueType) !== -1) { 171 | log.warn('WARNING: Property key "' + key + '" with invalid value type ' + valueType + ', ignoring'); 172 | value = null; 173 | } else if (valueType === 'undefined') { 174 | value = null; 175 | } else if (valueType === 'error') { 176 | value = String(value); 177 | log.warn('WARNING: Property key "' + key + '" with value type error, coercing to ' + value); 178 | } else if (valueType === 'array') { 179 | // check for nested arrays or objects 180 | var arrayCopy = []; 181 | for (var i = 0; i < value.length; i++) { 182 | var element = value[i]; 183 | var elemType = type(element); 184 | if (elemType === 'array') { 185 | log.warn('WARNING: Cannot have ' + elemType + ' nested in an array property value, skipping'); 186 | continue; 187 | } else if (elemType === 'object') { 188 | arrayCopy.push(validateProperties(element)); 189 | } else { 190 | arrayCopy.push(validatePropertyValue(key, element)); 191 | } 192 | } 193 | value = arrayCopy; 194 | } else if (valueType === 'object') { 195 | value = validateProperties(value); 196 | } 197 | return value; 198 | }; 199 | 200 | var validateGroups = function validateGroups(groups) { 201 | var groupsType = type(groups); 202 | if (groupsType !== 'object') { 203 | log.error('Error: invalid groups format. Expecting Javascript object, received ' + groupsType + ', ignoring'); 204 | return {}; 205 | } 206 | 207 | var copy = {}; // create a copy with all of the valid properties 208 | for (var group in groups) { 209 | if (!Object.prototype.hasOwnProperty.call(groups, group)) { 210 | continue; 211 | } 212 | 213 | // validate key 214 | var key = group; 215 | var keyType = type(key); 216 | if (keyType !== 'string') { 217 | key = String(key); 218 | log.warn('WARNING: Non-string groupType, received type ' + keyType + ', coercing to string "' + key + '"'); 219 | } 220 | 221 | // validate value 222 | var value = validateGroupName(key, groups[group]); 223 | if (value === null) { 224 | continue; 225 | } 226 | copy[key] = value; 227 | } 228 | return copy; 229 | }; 230 | 231 | var validateGroupName = function validateGroupName(key, groupName) { 232 | var groupNameType = type(groupName); 233 | if (groupNameType === 'string') { 234 | return groupName; 235 | } 236 | if (groupNameType === 'date' || groupNameType === 'number' || groupNameType === 'boolean') { 237 | groupName = String(groupName); 238 | log.warn( 239 | 'WARNING: Non-string groupName, received type ' + groupNameType + ', coercing to string "' + groupName + '"', 240 | ); 241 | return groupName; 242 | } 243 | if (groupNameType === 'array') { 244 | // check for nested arrays or objects 245 | var arrayCopy = []; 246 | for (var i = 0; i < groupName.length; i++) { 247 | var element = groupName[i]; 248 | var elemType = type(element); 249 | if (elemType === 'array' || elemType === 'object') { 250 | log.warn('WARNING: Skipping nested ' + elemType + ' in array groupName'); 251 | continue; 252 | } else if (elemType === 'string') { 253 | arrayCopy.push(element); 254 | } else if (elemType === 'date' || elemType === 'number' || elemType === 'boolean') { 255 | element = String(element); 256 | log.warn('WARNING: Non-string groupName, received type ' + elemType + ', coercing to string "' + element + '"'); 257 | arrayCopy.push(element); 258 | } 259 | } 260 | return arrayCopy; 261 | } 262 | log.warn( 263 | 'WARNING: Non-string groupName, received type ' + 264 | groupNameType + 265 | '. Please use strings or array of strings for groupName', 266 | ); 267 | }; 268 | 269 | // parses the value of a url param (for example ?gclid=1234&...) 270 | var getQueryParam = function getQueryParam(name, query) { 271 | name = name.replace(/[[]/, '\\[').replace(/[\]]/, '\\]'); 272 | var regex = new RegExp('[\\?&]' + name + '=([^&#]*)'); 273 | var results = regex.exec(query); 274 | return results === null ? undefined : decodeURIComponent(results[1].replace(/\+/g, ' ')); 275 | }; 276 | 277 | const isWebWorkerEnvironment = () => { 278 | return typeof WorkerGlobalScope !== 'undefined'; 279 | }; 280 | 281 | const validateSessionId = (sessionId) => { 282 | if (validateInput(sessionId, 'sessionId', 'number') && new Date(sessionId).getTime() > 0) { 283 | return true; 284 | } 285 | 286 | log.error(`sessionId value must in milliseconds since epoch (Unix Timestamp)`); 287 | return false; 288 | }; 289 | 290 | const getLocation = () => { 291 | return GlobalScope.location; 292 | }; 293 | 294 | const getHost = (url) => { 295 | const defaultHostname = GlobalScope.location ? GlobalScope.location.hostname : ''; 296 | if (url) { 297 | if (typeof document !== 'undefined') { 298 | const a = document.createElement('a'); 299 | a.href = url; 300 | return a.hostname || defaultHostname; 301 | } 302 | if (typeof URL === 'function') { 303 | const u = new URL(url); 304 | return u.hostname || defaultHostname; 305 | } 306 | } 307 | return defaultHostname; 308 | }; 309 | 310 | export default { 311 | setLogLevel, 312 | getLogLevel, 313 | logLevels, 314 | log, 315 | isEmptyString, 316 | isWebWorkerEnvironment, 317 | getQueryParam, 318 | sessionStorageEnabled, 319 | truncate, 320 | validateGroups, 321 | validateInput, 322 | validateProperties, 323 | validateDeviceId, 324 | validateTransport, 325 | validateSessionId, 326 | getLocation, 327 | getHost, 328 | }; 329 | -------------------------------------------------------------------------------- /src/identify.js: -------------------------------------------------------------------------------- 1 | import type from './type'; 2 | import utils from './utils'; 3 | 4 | /* 5 | * Wrapper for a user properties JSON object that supports operations. 6 | * Note: if a user property is used in multiple operations on the same Identify object, 7 | * only the first operation will be saved, and the rest will be ignored. 8 | */ 9 | 10 | var AMP_OP_ADD = '$add'; 11 | var AMP_OP_APPEND = '$append'; 12 | var AMP_OP_CLEAR_ALL = '$clearAll'; 13 | var AMP_OP_PREPEND = '$prepend'; 14 | var AMP_OP_SET = '$set'; 15 | var AMP_OP_SET_ONCE = '$setOnce'; 16 | var AMP_OP_UNSET = '$unset'; 17 | var AMP_OP_PREINSERT = '$preInsert'; 18 | var AMP_OP_POSTINSERT = '$postInsert'; 19 | var AMP_OP_REMOVE = '$remove'; 20 | 21 | /** 22 | * Identify API - instance constructor. Identify objects are a wrapper for user property operations. 23 | * Each method adds a user property operation to the Identify object, and returns the same Identify object, 24 | * allowing you to chain multiple method calls together. 25 | * Note: if the same user property is used in multiple operations on a single Identify object, 26 | * only the first operation on that property will be saved, and the rest will be ignored. 27 | * @constructor Identify 28 | * @public 29 | * @example var identify = new amplitude.Identify(); 30 | */ 31 | var Identify = function () { 32 | this.userPropertiesOperations = {}; 33 | this.properties = []; // keep track of keys that have been added 34 | }; 35 | 36 | /** 37 | * Increment a user property by a given value (can also be negative to decrement). 38 | * If the user property does not have a value set yet, it will be initialized to 0 before being incremented. 39 | * @public 40 | * @param {string} property - The user property key. 41 | * @param {number|string} value - The amount by which to increment the user property. Allows numbers as strings (ex: '123'). 42 | * @return {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 43 | * @example var identify = new amplitude.Identify().add('karma', 1).add('friends', 1); 44 | * amplitude.identify(identify); // send the Identify call 45 | */ 46 | Identify.prototype.add = function (property, value) { 47 | if (type(value) === 'number' || type(value) === 'string') { 48 | this._addOperation(AMP_OP_ADD, property, value); 49 | } else { 50 | utils.log.error('Unsupported type for value: ' + type(value) + ', expecting number or string'); 51 | } 52 | return this; 53 | }; 54 | 55 | /** 56 | * Append a value or values to a user property. 57 | * If the user property does not have a value set yet, 58 | * it will be initialized to an empty list before the new values are appended. 59 | * If the user property has an existing value and it is not a list, 60 | * the existing value will be converted into a list with the new values appended. 61 | * @public 62 | * @param {string} property - The user property key. 63 | * @param {number|string|list|object} value - A value or values to append. 64 | * Values can be numbers, strings, lists, or object (key:value dict will be flattened). 65 | * @return {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 66 | * @example var identify = new amplitude.Identify().append('ab-tests', 'new-user-tests'); 67 | * identify.append('some_list', [1, 2, 3, 4, 'values']); 68 | * amplitude.identify(identify); // send the Identify call 69 | */ 70 | Identify.prototype.append = function (property, value) { 71 | this._addOperation(AMP_OP_APPEND, property, value); 72 | return this; 73 | }; 74 | 75 | /** 76 | * Clear all user properties for the current user. 77 | * SDK user should instead call amplitude.clearUserProperties() instead of using this. 78 | * $clearAll needs to be sent on its own Identify object. If there are already other operations, then don't add $clearAll. 79 | * If $clearAll already in an Identify object, don't allow other operations to be added. 80 | * @private 81 | */ 82 | Identify.prototype.clearAll = function () { 83 | if (Object.keys(this.userPropertiesOperations).length > 0) { 84 | if (!Object.prototype.hasOwnProperty.call(this.userPropertiesOperations, AMP_OP_CLEAR_ALL)) { 85 | utils.log.error( 86 | 'Need to send $clearAll on its own Identify object without any other operations, skipping $clearAll', 87 | ); 88 | } 89 | return this; 90 | } 91 | this.userPropertiesOperations[AMP_OP_CLEAR_ALL] = '-'; 92 | return this; 93 | }; 94 | 95 | /** 96 | * Prepend a value or values to a user property. 97 | * Prepend means inserting the value or values at the front of a list. 98 | * If the user property does not have a value set yet, 99 | * it will be initialized to an empty list before the new values are prepended. 100 | * If the user property has an existing value and it is not a list, 101 | * the existing value will be converted into a list with the new values prepended. 102 | * @public 103 | * @param {string} property - The user property key. 104 | * @param {number|string|list|object} value - A value or values to prepend. 105 | * Values can be numbers, strings, lists, or object (key:value dict will be flattened). 106 | * @return {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 107 | * @example var identify = new amplitude.Identify().prepend('ab-tests', 'new-user-tests'); 108 | * identify.prepend('some_list', [1, 2, 3, 4, 'values']); 109 | * amplitude.identify(identify); // send the Identify call 110 | */ 111 | Identify.prototype.prepend = function (property, value) { 112 | this._addOperation(AMP_OP_PREPEND, property, value); 113 | return this; 114 | }; 115 | 116 | /** 117 | * Sets the value of a given user property. If a value already exists, it will be overwriten with the new value. 118 | * @public 119 | * @param {string} property - The user property key. 120 | * @param {number|string|list|boolean|object} value - A value or values to set. 121 | * Values can be numbers, strings, lists, or object (key:value dict will be flattened). 122 | * @return {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 123 | * @example var identify = new amplitude.Identify().set('user_type', 'beta'); 124 | * identify.set('name', {'first': 'John', 'last': 'Doe'}); // dict is flattened and becomes name.first: John, name.last: Doe 125 | * amplitude.identify(identify); // send the Identify call 126 | */ 127 | Identify.prototype.set = function (property, value) { 128 | this._addOperation(AMP_OP_SET, property, value); 129 | return this; 130 | }; 131 | 132 | /** 133 | * Sets the value of a given user property only once. Subsequent setOnce operations on that user property will be ignored; 134 | * however, that user property can still be modified through any of the other operations. 135 | * Useful for capturing properties such as 'initial_signup_date', 'initial_referrer', etc. 136 | * @public 137 | * @param {string} property - The user property key. 138 | * @param {number|string|list|boolean|object} value - A value or values to set once. 139 | * Values can be numbers, strings, lists, or object (key:value dict will be flattened). 140 | * @return {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 141 | * @example var identify = new amplitude.Identify().setOnce('sign_up_date', '2016-04-01'); 142 | * amplitude.identify(identify); // send the Identify call 143 | */ 144 | Identify.prototype.setOnce = function (property, value) { 145 | this._addOperation(AMP_OP_SET_ONCE, property, value); 146 | return this; 147 | }; 148 | 149 | /** 150 | * Unset and remove a user property. This user property will no longer show up in a user's profile. 151 | * @public 152 | * @param {string} property - The user property key. 153 | * @return {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 154 | * @example var identify = new amplitude.Identify().unset('user_type').unset('age'); 155 | * amplitude.identify(identify); // send the Identify call 156 | */ 157 | Identify.prototype.unset = function (property) { 158 | this._addOperation(AMP_OP_UNSET, property, '-'); 159 | return this; 160 | }; 161 | 162 | /** 163 | * Preinsert a value or values to a user property, if it does not exist in the user property already. 164 | * Preinsert means inserting the value or values to the beginning of the specified user property. 165 | * If the item already exists in the user property, it will be a no-op. 166 | * @public 167 | * @param {string} property - The user property key. 168 | * @param {number|string|list|object} value - A value or values to insert. 169 | * @returns {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 170 | */ 171 | Identify.prototype.preInsert = function (property, value) { 172 | this._addOperation(AMP_OP_PREINSERT, property, value); 173 | return this; 174 | }; 175 | 176 | /** 177 | * Postinsert a value or values to a user property, if it does not exist in the user property already. 178 | * Postinsert means inserting the value or values to the beginning of the specified user property. 179 | * If the item already exists in the user property, it will be a no-op. 180 | * @param {string} property - The user property key. 181 | * @param {number|string|list|object} value - A value or values to insert. 182 | * @returns {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 183 | */ 184 | Identify.prototype.postInsert = function (property, value) { 185 | this._addOperation(AMP_OP_POSTINSERT, property, value); 186 | return this; 187 | }; 188 | 189 | /** 190 | * Remove a value or values to a user property, if it does exist in the user property. 191 | * If the item does not exist in the user property, it will be a no-op. 192 | * @param {string} property - The user property key. 193 | * @param {number|string|list|object} value - A value or values to remove. 194 | * @returns {Identify} Returns the same Identify object, allowing you to chain multiple method calls together. 195 | */ 196 | Identify.prototype.remove = function (property, value) { 197 | this._addOperation(AMP_OP_REMOVE, property, value); 198 | return this; 199 | }; 200 | 201 | /** 202 | * Helper function that adds operation to the Identify's object 203 | * Handle's filtering of duplicate user property keys, and filtering for clearAll. 204 | * @private 205 | */ 206 | Identify.prototype._addOperation = function (operation, property, value) { 207 | // check that the identify doesn't already contain a clearAll 208 | if (Object.prototype.hasOwnProperty.call(this.userPropertiesOperations, AMP_OP_CLEAR_ALL)) { 209 | utils.log.error('This identify already contains a $clearAll operation, skipping operation ' + operation); 210 | return; 211 | } 212 | 213 | // check that property wasn't already used in this Identify 214 | if (this.properties.indexOf(property) !== -1) { 215 | utils.log.error('User property "' + property + '" already used in this identify, skipping operation ' + operation); 216 | return; 217 | } 218 | 219 | if (!Object.prototype.hasOwnProperty.call(this.userPropertiesOperations, operation)) { 220 | this.userPropertiesOperations[operation] = {}; 221 | } 222 | this.userPropertiesOperations[operation][property] = value; 223 | this.properties.push(property); 224 | }; 225 | 226 | export default Identify; 227 | --------------------------------------------------------------------------------