├── apps └── sandbox │ ├── cypress.json │ ├── cypress │ ├── tsconfig.json │ ├── fixtures │ │ └── example.json │ ├── support │ │ ├── index.js │ │ └── commands.js │ ├── plugins │ │ └── index.js │ └── integration │ │ ├── browser-tracker-with-sampling.test.ts │ │ ├── javascript-tracker-with-sampling.test.ts │ │ ├── browser-tracker.test.ts │ │ └── javascript-tracker.test.ts │ ├── README.md │ ├── .gitignore │ ├── public │ ├── index.html │ ├── behavioral-analytics-browser-tracker.js │ └── index.global.js.map │ ├── package.json │ └── src │ └── index.tsx ├── packages ├── core │ ├── src │ │ ├── index.ts │ │ ├── dataproviders │ │ │ ├── index.ts │ │ │ └── page_attributes.ts │ │ ├── util │ │ │ ├── uuid.ts │ │ │ └── cookies.ts │ │ ├── user_session_store.ts │ │ ├── types.ts │ │ └── tracker.ts │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── test │ │ ├── util │ │ │ ├── uuid.test.ts │ │ │ └── cookies.test.ts │ │ ├── support.ts │ │ ├── dataproviders │ │ │ └── index.test.ts │ │ ├── tracker.test.ts │ │ └── userSessionStore.test.ts │ ├── jest.config.js │ ├── tsup.config.js │ ├── package.json │ └── CHANGELOG.md ├── browser-tracker │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── src │ │ ├── util │ │ │ └── script-attribute.ts │ │ └── index.ts │ ├── jest.config.js │ ├── tsup.config.js │ ├── package.json │ ├── CHANGELOG.md │ ├── test │ │ └── index.test.ts │ └── README.md ├── javascript-tracker │ ├── .eslintrc.cjs │ ├── tsconfig.json │ ├── jest.config.js │ ├── tsup.config.js │ ├── package.json │ ├── CHANGELOG.md │ ├── src │ │ └── index.ts │ ├── test │ │ └── integration.test.ts │ └── README.md ├── tsconfig │ ├── README.md │ ├── package.json │ └── base.json └── eslint-config-custom │ ├── package.json │ └── index.js ├── .gitignore ├── .prettierrc ├── .eslintrc.js ├── .changeset └── config.json ├── makefile ├── turbo.json ├── .github └── workflows │ ├── lint.yml │ ├── test.yml │ └── cypress.yml ├── package.json ├── .vscode └── launch.json └── README.md /apps/sandbox/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "video": false 3 | } 4 | -------------------------------------------------------------------------------- /packages/core/src/index.ts: -------------------------------------------------------------------------------- 1 | export { Tracker } from "./tracker"; 2 | export * from "./types"; 3 | -------------------------------------------------------------------------------- /packages/core/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .turbo 2 | build/** 3 | dist/** 4 | packages/**/dist 5 | .next/** 6 | .idea 7 | node_modules 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "es5", 4 | "printWidth": 100 5 | } 6 | -------------------------------------------------------------------------------- /packages/browser-tracker/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/javascript-tracker/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ["custom"], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/tsconfig/README.md: -------------------------------------------------------------------------------- 1 | # `tsconfig` 2 | 3 | These are base shared `tsconfig.json`s from which all other `tsconfig.json`'s inherit from. 4 | -------------------------------------------------------------------------------- /packages/core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/tsconfig/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsconfig", 3 | "version": "0.0.0", 4 | "private": true, 5 | "files": [ 6 | "base.json" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /packages/browser-tracker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /packages/javascript-tracker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tsconfig/base.json", 3 | "include": ["."], 4 | "exclude": ["dist", "build", "node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["es5", "dom"], 5 | "types": ["cypress", "node"] 6 | }, 7 | "include": ["**/*.ts"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/core/src/dataproviders/index.ts: -------------------------------------------------------------------------------- 1 | import pageAttributes from "./page_attributes"; 2 | 3 | export { pageAttributes }; 4 | 5 | export const DEFAULT_DATA_PROVIDERS = { 6 | pageAttributes, 7 | }; 8 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /packages/browser-tracker/src/util/script-attribute.ts: -------------------------------------------------------------------------------- 1 | export function getScriptAttribute(attributeName: string) { 2 | const scriptElement = document.currentScript; 3 | return scriptElement?.getAttribute(attributeName); 4 | } 5 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | // This tells ESLint to load the config from the package `eslint-config-custom` 4 | extends: ["custom"], 5 | settings: { 6 | next: { 7 | rootDir: ["apps/*/"], 8 | }, 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/core/src/util/uuid.ts: -------------------------------------------------------------------------------- 1 | export function uuidv4() { 2 | // @ts-ignore 3 | return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, function (c) { 4 | return (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16); 5 | }); 6 | } 7 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@2.2.0/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "restricted", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [] 11 | } 12 | -------------------------------------------------------------------------------- /makefile: -------------------------------------------------------------------------------- 1 | deploy: 2 | [ -n "$(ent_search_dir)" ] || { echo "No ENT-SEARCH directory specified. Use ent_search_dir argument to specify location of directory."; exit 1; } 3 | echo "Running Build" 4 | yarn build 5 | echo "Copying files to $(ent_search_dir)" 6 | cp -r packages/browser-tracker/dist/iife/index.js $(ent_search_dir)/public/analytics.js 7 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "eslint-config-custom", 3 | "version": "0.0.0", 4 | "main": "index.js", 5 | "private": true, 6 | "dependencies": { 7 | "eslint": "latest", 8 | "eslint-config-prettier": "latest", 9 | "eslint-plugin-react": "latest", 10 | "eslint-config-turbo": "latest" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turbo.build/schema.json", 3 | "pipeline": { 4 | "build": { 5 | "dependsOn": ["^build"] 6 | }, 7 | "test": { 8 | "dependsOn": ["build"], 9 | "outputs": [], 10 | "inputs": ["src/**/*.ts", "test/**/*.ts"] 11 | }, 12 | "lint": { 13 | "outputs": [] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /apps/sandbox/README.md: -------------------------------------------------------------------------------- 1 | # Sandbox App 2 | 3 | For integration tests. Tests are done within cypress. 4 | 5 | ## Running the tests locally 6 | 7 | 0. Deploy the browser tracker code by `yarn deploy` 8 | 1. `cd apps/sandbox` 9 | 2. `yarn start` 10 | 3. in another terminal, run `yarn e2e` 11 | 12 | This will start the CRA app and also run cypress against the CRA app. 13 | -------------------------------------------------------------------------------- /apps/sandbox/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/core/test/util/uuid.test.ts: -------------------------------------------------------------------------------- 1 | import { uuidv4 } from "../../src/util/uuid"; 2 | 3 | describe("uuidv4", () => { 4 | const UUIDV4_FORMAT_REGEX = /^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$/; 5 | 6 | test("uuidv4 has the right format", () => { 7 | expect(uuidv4()).toMatch(UUIDV4_FORMAT_REGEX); 8 | }); 9 | 10 | test("a new uuid is generated at each call", () => { 11 | expect(uuidv4()).not.toEqual(uuidv4()); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /packages/browser-tracker/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: "jsdom", 3 | testTimeout: 70000, 4 | preset: "ts-jest/presets/default-esm", // or other ESM presets 5 | moduleNameMapper: { 6 | "^(\\.{1,2}/.*)\\.js$": "$1", 7 | }, 8 | transform: { 9 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` 10 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` 11 | "^.+\\.tsx?$": [ 12 | "ts-jest", 13 | { 14 | useESM: true, 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/javascript-tracker/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: "jsdom", 3 | testTimeout: 70000, 4 | preset: "ts-jest/presets/default-esm", // or other ESM presets 5 | moduleNameMapper: { 6 | "^(\\.{1,2}/.*)\\.js$": "$1", 7 | }, 8 | transform: { 9 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` 10 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` 11 | "^.+\\.tsx?$": [ 12 | "ts-jest", 13 | { 14 | useESM: true, 15 | }, 16 | ], 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /packages/core/jest.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | testEnvironment: "jsdom", 3 | testTimeout: 70000, 4 | preset: "ts-jest/presets/default-esm", // or other ESM presets 5 | moduleNameMapper: { 6 | "^(\\.{1,2}/.*)\\.js$": "$1", 7 | }, 8 | setupFilesAfterEnv: ["/test/support.ts"], 9 | transform: { 10 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` 11 | // '^.+\\.m?[tj]sx?$' to process js/ts/mjs/mts with `ts-jest` 12 | "^.+\\.tsx?$": [ 13 | "ts-jest", 14 | { 15 | useESM: true, 16 | }, 17 | ], 18 | }, 19 | }; 20 | -------------------------------------------------------------------------------- /packages/core/src/util/cookies.ts: -------------------------------------------------------------------------------- 1 | export function getCookie(name: string) { 2 | const value = "; " + document.cookie; 3 | const parts = value.split("; " + name + "="); 4 | 5 | if (parts.length === 2 && parts[1]) { 6 | return parts.pop()?.split(";").shift(); 7 | } 8 | } 9 | 10 | export function setCookie( 11 | cookieName: string, 12 | cookieValue: string, 13 | expiresAt: Date, 14 | path: string = "/" 15 | ) { 16 | var expires = "expires=" + expiresAt.toUTCString(); 17 | document.cookie = cookieName + "=" + cookieValue + "; " + expires + "; path=" + path; 18 | } 19 | -------------------------------------------------------------------------------- /apps/sandbox/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | React App 10 | 11 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /packages/core/src/dataproviders/page_attributes.ts: -------------------------------------------------------------------------------- 1 | import { EventProperties, PageEventAttribute, TrackerEventType } from "../types"; 2 | 3 | export default (eventType: TrackerEventType, properties: EventProperties) => { 4 | const referrer = document.referrer || ""; 5 | 6 | if (eventType === "page_view") { 7 | return { 8 | ...properties, 9 | page: { 10 | ...(properties.page || {}), 11 | referrer: referrer, 12 | url: window.location.href, 13 | title: document.title, 14 | } as PageEventAttribute, 15 | }; 16 | } 17 | return properties; 18 | }; 19 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint Checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | pull_request: 9 | branches: 10 | - main 11 | 12 | jobs: 13 | lint-run: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | with: 19 | fetch-depth: 2 20 | 21 | - name: Setup Node.js environment 22 | uses: actions/setup-node@v3 23 | with: 24 | node-version: 16 25 | cache: "yarn" 26 | 27 | - name: Install dependencies 28 | run: yarn 29 | 30 | - name: Lint Checks 31 | run: yarn lint 32 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 2.x 8 | pull_request: 9 | branches: 10 | - main 11 | - 2.x 12 | 13 | jobs: 14 | unit-tests: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 2 21 | 22 | - name: Setup Node.js environment 23 | uses: actions/setup-node@v3 24 | with: 25 | node-version: 16 26 | cache: "yarn" 27 | 28 | - name: Install dependencies 29 | run: yarn 30 | 31 | - name: Test 32 | run: yarn test 33 | -------------------------------------------------------------------------------- /packages/javascript-tracker/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import path from "path"; 3 | 4 | const commonConfig = { 5 | clean: true, 6 | dts: true, 7 | sourcemap: true, 8 | tsconfig: path.resolve(__dirname, "./tsconfig.json"), 9 | }; 10 | export default defineConfig([ 11 | { 12 | entry: ["./src/index.ts"], 13 | ...commonConfig, 14 | // NOTE: it means CJS will be .js and ESM will be .mjs 15 | format: ["esm"], 16 | outDir: "dist", 17 | }, 18 | { 19 | entry: ["./src/index.ts"], 20 | ...commonConfig, 21 | // NOTE: it means CJS will be .js and ESM will be .mjs 22 | format: ["cjs"], 23 | outDir: "dist", 24 | }, 25 | ]); 26 | -------------------------------------------------------------------------------- /packages/tsconfig/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "display": "Default", 4 | "compilerOptions": { 5 | "composite": false, 6 | "declaration": true, 7 | "declarationMap": true, 8 | "esModuleInterop": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "inlineSources": false, 11 | "isolatedModules": true, 12 | "moduleResolution": "node", 13 | "module": "esnext", 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": false, 16 | "preserveWatchOutput": true, 17 | "skipLibCheck": true, 18 | "strict": true, 19 | "resolveJsonModule": true 20 | }, 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /.github/workflows/cypress.yml: -------------------------------------------------------------------------------- 1 | name: Cypress Tests 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - 2.x 8 | pull_request: 9 | branches: 10 | - main 11 | - 2.x 12 | 13 | jobs: 14 | cypress-run: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v3 19 | with: 20 | fetch-depth: 2 21 | 22 | - name: Install dependencies 23 | run: yarn 24 | 25 | - name: Build Packages 26 | run: yarn build 27 | 28 | - name: Cypress run 29 | uses: cypress-io/github-action@v4 30 | with: 31 | working-directory: ./apps/sandbox 32 | start: npm start 33 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import "./commands"; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "apps/*", 5 | "packages/*" 6 | ], 7 | "scripts": { 8 | "build": "turbo run build", 9 | "test": "turbo run test", 10 | "dev": "turbo run dev", 11 | "lint": "turbo run lint", 12 | "format": "prettier --write .", 13 | "publish-packages": "yarn build && yarn lint && yarn test && changeset version && changeset publish" 14 | }, 15 | "devDependencies": { 16 | "@changesets/cli": "^2.25.2", 17 | "@types/jest": "^29.2.3", 18 | "jest": "^29.0.2", 19 | "jest-environment-jsdom": "^29.0.2", 20 | "moment": "^2.29.4", 21 | "prettier": "^2.8.8", 22 | "ts-jest": "29.0.3", 23 | "turbo": "^1.6.3", 24 | "typescript": "^4.5.2" 25 | }, 26 | "version": "0.0.0" 27 | } 28 | -------------------------------------------------------------------------------- /packages/core/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import path from "path"; 3 | 4 | const commonConfig = { 5 | clean: true, 6 | splitting: false, 7 | // Skip until .d.ts.map is also supported https://github.com/egoist/tsup/issues/564 8 | dts: true, 9 | sourcemap: true, 10 | tsconfig: path.resolve(__dirname, "./tsconfig.json"), 11 | }; 12 | export default defineConfig([ 13 | { 14 | entry: ["./src/index.ts"], 15 | ...commonConfig, 16 | // NOTE: it means CJS will be .js and ESM will be .mjs 17 | format: ["esm"], 18 | outDir: "dist", 19 | }, 20 | { 21 | entry: ["./src/index.ts"], 22 | ...commonConfig, 23 | // NOTE: it means CJS will be .js and ESM will be .mjs 24 | format: ["cjs"], 25 | outDir: "dist", 26 | }, 27 | ]); 28 | -------------------------------------------------------------------------------- /packages/eslint-config-custom/index.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | "jest/globals": true, 5 | }, 6 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended", "prettier"], 7 | plugins: ["@typescript-eslint", "jest"], 8 | rules: { 9 | "@typescript-eslint/no-empty-interface": "off", 10 | "no-extra-boolean-cast": "off", 11 | "no-console": ["error", { allow: ["warn", "error"] }], 12 | }, 13 | parser: "@typescript-eslint/parser", 14 | overrides: [ 15 | { 16 | files: ["test/**", "*.test.ts", "*.test.js"], 17 | rules: { 18 | "@typescript-eslint/ban-ts-comment": "off", 19 | "@typescript-eslint/no-empty-function": "off", 20 | "no-undef": "off", 21 | }, 22 | }, 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /packages/core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elastic/behavioral-analytics-tracker-core", 3 | "version": "2.0.6", 4 | "main": "./dist/index.js", 5 | "sideEffects": false, 6 | "types": "./dist/index.d.ts", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs", 12 | "types": "./dist/index.d.ts" 13 | }, 14 | "./package": "./package.json", 15 | "./package.json": "./package.json" 16 | }, 17 | "scripts": { 18 | "build": "tsup", 19 | "test": "jest", 20 | "lint": "eslint **/*.ts*" 21 | }, 22 | "devDependencies": { 23 | "eslint-config-custom": "*", 24 | "tsconfig": "*", 25 | "tsup": "^6.3.0" 26 | }, 27 | "publishConfig": { 28 | "access": "public" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /packages/core/test/support.ts: -------------------------------------------------------------------------------- 1 | import { randomFillSync } from "crypto"; 2 | // @ts-ignore 3 | global.crypto = { 4 | // @ts-ignore 5 | getRandomValues: function (buffer) { 6 | return randomFillSync(buffer as unknown as DataView); 7 | }, 8 | }; 9 | 10 | export function getCookie(name: string) { 11 | const value = "; " + document.cookie; 12 | const parts = value.split("; " + name + "="); 13 | 14 | if (parts.length === 2 && parts[1]) { 15 | return parts.pop()?.split(";").shift(); 16 | } 17 | } 18 | export function getCookieExpirationDate(name: string) { 19 | const value = "; " + document.cookie; 20 | const parts = value.split("; " + name + "="); 21 | 22 | if (parts.length === 2 && parts[1]) { 23 | return parts.pop()?.split(";")[1].replace(" expires=", ""); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/plugins/index.js: -------------------------------------------------------------------------------- 1 | /// 2 | // *********************************************************** 3 | // This example plugins/index.js can be used to load plugins 4 | // 5 | // You can change the location of this file or turn off loading 6 | // the plugins file with the 'pluginsFile' configuration option. 7 | // 8 | // You can read more here: 9 | // https://on.cypress.io/plugins-guide 10 | // *********************************************************** 11 | 12 | // This function is called when a project is opened or re-opened (e.g. due to 13 | // the project's config changing) 14 | 15 | /** 16 | * @type {Cypress.PluginConfig} 17 | */ 18 | // eslint-disable-next-line no-unused-vars 19 | module.exports = (on, config) => { 20 | // `on` is used to hook into various events Cypress emits 21 | // `config` is the resolved Cypress config 22 | }; 23 | -------------------------------------------------------------------------------- /packages/javascript-tracker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elastic/behavioral-analytics-javascript-tracker", 3 | "version": "2.1.4", 4 | "main": "./dist/index.js", 5 | "sideEffects": false, 6 | "types": "./dist/index.d.ts", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "import": "./dist/index.js", 11 | "require": "./dist/index.cjs", 12 | "types": "./dist/index.d.ts" 13 | }, 14 | "./package": "./package.json", 15 | "./package.json": "./package.json" 16 | }, 17 | "scripts": { 18 | "build": "tsup", 19 | "lint": "eslint **/*.ts*", 20 | "test": "jest" 21 | }, 22 | "dependencies": { 23 | "@elastic/behavioral-analytics-tracker-core": "2.0.6" 24 | }, 25 | "devDependencies": { 26 | "eslint-config-custom": "*", 27 | "tsconfig": "*", 28 | "tsup": "^6.3.0" 29 | }, 30 | "publishConfig": { 31 | "access": "public" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/integration/browser-tracker-with-sampling.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-expect-in-promise */ 2 | 3 | describe("browser tracker with sampling", () => { 4 | it("should work", () => { 5 | cy.intercept( 6 | "https://my-browser-analytics-dsn.elastic.co/_application/analytics/test/event/*", 7 | cy.spy().as("TrackerEvent") 8 | ); 9 | 10 | cy.visit("http://localhost:3000/browser-tracker-with-sampling"); 11 | 12 | cy.wait(200); 13 | 14 | cy.get("@TrackerEvent").should("not.have.been.called"); 15 | 16 | cy.get(".click-event").click(); 17 | cy.get("@TrackerEvent").should("not.have.been.called"); 18 | 19 | cy.get(".search-event").click(); 20 | cy.get("@TrackerEvent").should("not.have.been.called"); 21 | 22 | cy.getCookie("EA_SID").should("exist"); 23 | cy.getCookie("EA_UID").should("exist"); 24 | cy.getCookie("EA_SESSION_SAMPLED").should("have.property", "value", "false"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/integration/javascript-tracker-with-sampling.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-expect-in-promise */ 2 | 3 | describe("Javascript tracker with sampling", () => { 4 | it("should work", () => { 5 | cy.intercept( 6 | "https://my-browser-analytics-dsn.elastic.co/_application/analytics/test/event/*", 7 | cy.spy().as("TrackerEvent") 8 | ); 9 | 10 | cy.visit("http://localhost:3000/javascript-tracker-with-sampling"); 11 | 12 | cy.wait(200); 13 | 14 | cy.get("@TrackerEvent").should("not.have.been.called"); 15 | 16 | cy.get(".click-event").click(); 17 | cy.get("@TrackerEvent").should("not.have.been.called"); 18 | 19 | cy.get(".search-event").click(); 20 | cy.get("@TrackerEvent").should("not.have.been.called"); 21 | 22 | cy.getCookie("EA_SID").should("exist"); 23 | cy.getCookie("EA_UID").should("exist"); 24 | cy.getCookie("EA_SESSION_SAMPLED").should("have.property", "value", "false"); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/browser-tracker/tsup.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsup"; 2 | import path from "path"; 3 | 4 | const commonConfig = { 5 | clean: true, 6 | dts: true, 7 | sourcemap: false, 8 | tsconfig: path.resolve(__dirname, "./tsconfig.json"), 9 | }; 10 | export default defineConfig([ 11 | { 12 | entry: ["./src/index.ts"], 13 | ...commonConfig, 14 | // NOTE: it means CJS will be .js and ESM will be .mjs 15 | format: ["esm"], 16 | outDir: "dist", 17 | }, 18 | { 19 | entry: ["./src/index.ts"], 20 | ...commonConfig, 21 | // NOTE: it means CJS will be .js and ESM will be .mjs 22 | format: ["cjs"], 23 | outDir: "dist", 24 | }, 25 | { 26 | entry: ["src/index.ts"], 27 | ...commonConfig, 28 | format: "iife", 29 | outDir: "dist", 30 | minify: true, 31 | legacyOutput: true, 32 | globalName: "elasticAnalyticsDefault", 33 | footer: { 34 | js: "var elasticAnalytics = elasticAnalyticsDefault.default", 35 | }, 36 | }, 37 | ]); 38 | -------------------------------------------------------------------------------- /packages/browser-tracker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@elastic/behavioral-analytics-browser-tracker", 3 | "version": "2.1.4", 4 | "main": "./dist/index.js", 5 | "types": "./dist/index.d.ts", 6 | "type": "module", 7 | "jsdelivr": "dist/iife/index.js", 8 | "unpkg": "dist/iife/index.js", 9 | "exports": { 10 | ".": { 11 | "import": "./dist/index.js", 12 | "require": "./dist/index.cjs", 13 | "types": "./dist/index.d.ts" 14 | }, 15 | "./package": "./package.json", 16 | "./package.json": "./package.json" 17 | }, 18 | "scripts": { 19 | "build": "tsup && yarn deploy", 20 | "test": "jest", 21 | "lint": "eslint **/*.ts*", 22 | "deploy": "cp dist/iife/index.js ../../apps/sandbox/public/behavioral-analytics-browser-tracker.js" 23 | }, 24 | "dependencies": { 25 | "@elastic/behavioral-analytics-tracker-core": "2.0.6" 26 | }, 27 | "devDependencies": { 28 | "eslint-config-custom": "*", 29 | "tsconfig": "*", 30 | "tsup": "^6.3.0" 31 | }, 32 | "publishConfig": { 33 | "access": "public" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /packages/core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @elastic/behavioral-analytics-tracker-core 2 | 3 | ## 2.0.6 4 | 5 | ### Patch Changes 6 | 7 | - Fix cookies issues 8 | 9 | ## 2.0.5 10 | 11 | ### Patch Changes 12 | 13 | - cb89f61: Include dist folder in core package publish 14 | 15 | ## 2.0.4 16 | 17 | ### Patch Changes 18 | 19 | - 4a4f16c: Publish core 20 | 21 | ## 2.0.3 22 | 23 | ### Patch Changes 24 | 25 | - Move core from devDep to dep 26 | 27 | ## 2.0.2 28 | 29 | ### Patch Changes 30 | 31 | - 97ea453: v.2.0.2 32 | 33 | ## 2.0.1 34 | 35 | ### Patch Changes 36 | 37 | - export types 38 | - d7ea5b2: Export types 39 | 40 | ## 2.0.0 41 | 42 | ### Major Changes 43 | 44 | - 1fb9178: V2 of tracker 45 | 46 | ### Patch Changes 47 | 48 | - Streaming support 49 | - 1fb9178: fix the imports for browser tracker 50 | 51 | ## 2.0.0-next.1 52 | 53 | ### Patch Changes 54 | 55 | - Streaming support 56 | - 1fb9178: fix the imports for browser tracker 57 | 58 | ## 2.0.0-next.0 59 | 60 | ### Major Changes 61 | 62 | - V2 of tracker 63 | 64 | ## 1.0.0 65 | 66 | ### Major Changes 67 | 68 | - First release of the core 69 | -------------------------------------------------------------------------------- /packages/core/test/dataproviders/index.test.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_DATA_PROVIDERS } from "../../src/dataproviders/index"; 2 | import { processEvent } from "../../src/tracker"; 3 | 4 | describe("default dataproviders", () => { 5 | it("should export a default dataprovider", () => { 6 | expect(DEFAULT_DATA_PROVIDERS).toBeDefined(); 7 | }); 8 | 9 | it("should put the event in payload", () => { 10 | expect( 11 | processEvent( 12 | "page_view", 13 | { 14 | search: { 15 | query: "test", 16 | }, 17 | }, 18 | DEFAULT_DATA_PROVIDERS 19 | ) 20 | ).toEqual({ 21 | search: { query: "test" }, 22 | page: { 23 | referrer: "", 24 | title: "", 25 | url: "http://localhost/", 26 | }, 27 | }); 28 | }); 29 | 30 | it("should put the event in payload", () => { 31 | expect( 32 | processEvent( 33 | "search", 34 | { 35 | search: { 36 | query: "test", 37 | }, 38 | }, 39 | DEFAULT_DATA_PROVIDERS 40 | ) 41 | ).toEqual({ 42 | search: { query: "test" }, 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /apps/sandbox/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sandbox", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^5.16.5", 7 | "@testing-library/react": "^13.4.0", 8 | "@testing-library/user-event": "^13.5.0", 9 | "@types/jest": "^29.2.3", 10 | "@types/node": "^18.11.9", 11 | "@types/react": "^18.0.25", 12 | "@types/react-dom": "^18.0.9", 13 | "react": "^18.2.0", 14 | "react-dom": "^18.2.0", 15 | "react-scripts": "5.0.1", 16 | "typescript": "^4.9.3", 17 | "web-vitals": "^2.1.4", 18 | "react-router-dom": "^6.4.3" 19 | }, 20 | "devDependencies": { 21 | "cypress": "^9.1.1" 22 | }, 23 | "scripts": { 24 | "e2e": "cypress run", 25 | "start": "react-scripts start", 26 | "eject": "react-scripts eject" 27 | }, 28 | "eslintConfig": { 29 | "extends": [ 30 | "react-app", 31 | "react-app/jest" 32 | ] 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/browser-tracker/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @elastic/behavioral-analytics-browser-tracker 2 | 3 | ## 2.1.4 4 | 5 | ### Patch Changes 6 | 7 | - Fix cookies issues 8 | - Updated dependencies 9 | - @elastic/behavioral-analytics-tracker-core@2.0.6 10 | 11 | ## 2.1.3 12 | 13 | ### Patch Changes 14 | 15 | - cb89f61: Include dist folder in core package publish 16 | - Updated dependencies [cb89f61] 17 | - @elastic/behavioral-analytics-tracker-core@2.0.5 18 | 19 | ## 2.1.2 20 | 21 | ### Patch Changes 22 | 23 | - Updated dependencies [4a4f16c] 24 | - @elastic/behavioral-analytics-tracker-core@2.0.4 25 | 26 | ## 2.1.1 27 | 28 | ### Patch Changes 29 | 30 | - Move core from devDep to dep 31 | - Updated dependencies 32 | - @elastic/behavioral-analytics-tracker-core@2.0.3 33 | 34 | ## 2.1.0 35 | 36 | ### Minor Changes 37 | 38 | - 97ea453: v.2.0.2 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies [97ea453] 43 | - @elastic/behavioral-analytics-tracker-core@2.0.2 44 | 45 | ## 2.0.0 46 | 47 | ### Major Changes 48 | 49 | - 1fb9178: V2 of tracker 50 | 51 | ### Patch Changes 52 | 53 | - Streaming support 54 | - 1fb9178: fix the imports for browser tracker 55 | 56 | ## 2.0.0-next.1 57 | 58 | ### Patch Changes 59 | 60 | - Streaming support 61 | - 1fb9178: fix the imports for browser tracker 62 | 63 | ## 2.0.0-next.0 64 | 65 | ### Major Changes 66 | 67 | - V2 of tracker 68 | 69 | ## 1.0.0 70 | 71 | ### Major Changes 72 | 73 | - First release of tracker 74 | -------------------------------------------------------------------------------- /packages/javascript-tracker/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @elastic/behavioral-analytics-javascript-tracker 2 | 3 | ## 2.1.4 4 | 5 | ### Patch Changes 6 | 7 | - Fix cookies issues 8 | - Updated dependencies 9 | - @elastic/behavioral-analytics-tracker-core@2.0.6 10 | 11 | ## 2.1.3 12 | 13 | ### Patch Changes 14 | 15 | - cb89f61: Include dist folder in core package publish 16 | - Updated dependencies [cb89f61] 17 | - @elastic/behavioral-analytics-tracker-core@2.0.5 18 | 19 | ## 2.1.2 20 | 21 | ### Patch Changes 22 | 23 | - Updated dependencies [4a4f16c] 24 | - @elastic/behavioral-analytics-tracker-core@2.0.4 25 | 26 | ## 2.1.1 27 | 28 | ### Patch Changes 29 | 30 | - Move core from devDep to dep 31 | - Updated dependencies 32 | - @elastic/behavioral-analytics-tracker-core@2.0.3 33 | 34 | ## 2.1.0 35 | 36 | ### Minor Changes 37 | 38 | - 97ea453: v.2.0.2 39 | 40 | ### Patch Changes 41 | 42 | - Updated dependencies [97ea453] 43 | - @elastic/behavioral-analytics-tracker-core@2.0.2 44 | 45 | ## 2.0.1 46 | 47 | ### Patch Changes 48 | 49 | - export types 50 | - d7ea5b2: Export types 51 | 52 | ## 2.0.0 53 | 54 | ### Major Changes 55 | 56 | - 1fb9178: V2 of tracker 57 | 58 | ### Patch Changes 59 | 60 | - Streaming support 61 | 62 | ## 2.0.0-next.1 63 | 64 | ### Patch Changes 65 | 66 | - Streaming support 67 | 68 | ## 2.0.0-next.0 69 | 70 | ### Major Changes 71 | 72 | - V2 of tracker 73 | 74 | ## 1.0.0 75 | 76 | ### Major Changes 77 | 78 | - First release of tracker 79 | -------------------------------------------------------------------------------- /packages/javascript-tracker/src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | PageViewInputProperties, 3 | SearchClickEventInputProperties, 4 | SearchEventInputProperties, 5 | Tracker, 6 | TrackerEventProperties, 7 | TrackerEventType, 8 | TrackerOptions, 9 | } from "@elastic/behavioral-analytics-tracker-core"; 10 | 11 | export type { 12 | PageViewInputProperties, 13 | SearchClickEventInputProperties, 14 | SearchEventInputProperties, 15 | TrackerEventProperties, 16 | TrackerEventType, 17 | TrackerOptions, 18 | } from "@elastic/behavioral-analytics-tracker-core"; 19 | 20 | let sharedTracker: Tracker | null = null; 21 | 22 | function getSharedTracker(): Tracker { 23 | if (sharedTracker === null) { 24 | throw new Error("Behavioral Analytics: Tracker not initialized."); 25 | } 26 | 27 | return sharedTracker; 28 | } 29 | 30 | export function createTracker(options: TrackerOptions) { 31 | sharedTracker = new Tracker(options); 32 | return sharedTracker; 33 | } 34 | 35 | export function getTracker() { 36 | return getSharedTracker(); 37 | } 38 | 39 | export function trackEvent(eventType: TrackerEventType, properties: TrackerEventProperties) { 40 | return getSharedTracker()?.trackEvent(eventType, properties); 41 | } 42 | 43 | export function trackPageView(properties?: PageViewInputProperties) { 44 | return getSharedTracker()?.trackPageView(properties); 45 | } 46 | 47 | export function trackSearch(properties: SearchEventInputProperties) { 48 | return getSharedTracker()?.trackSearch(properties); 49 | } 50 | 51 | export function trackSearchClick(properties: SearchClickEventInputProperties) { 52 | return getSharedTracker()?.trackSearchClick(properties); 53 | } 54 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "javascript tracker tests", 9 | "type": "node", 10 | "request": "launch", 11 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 12 | "args": ["--runInBand", "--forceExit", "--detectOpenHandles", "--watch"], 13 | "cwd": "${workspaceRoot}/packages/javascript-tracker", 14 | "env": { 15 | "NODE_ENV": "test" 16 | }, 17 | "console": "integratedTerminal", 18 | "sourceMaps": true 19 | }, 20 | { 21 | "name": "core tests", 22 | "type": "node", 23 | "request": "launch", 24 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 25 | "args": ["--runInBand", "--forceExit", "--detectOpenHandles", "--watch"], 26 | "cwd": "${workspaceRoot}/packages/core", 27 | "env": { 28 | "NODE_ENV": "test" 29 | }, 30 | "console": "integratedTerminal", 31 | "sourceMaps": true 32 | }, 33 | { 34 | "name": "browser tests", 35 | "type": "node", 36 | "request": "launch", 37 | "program": "${workspaceRoot}/node_modules/jest/bin/jest.js", 38 | "args": ["--runInBand", "--forceExit", "--detectOpenHandles", "--watch"], 39 | "cwd": "${workspaceRoot}/packages/browser-tracker", 40 | "env": { 41 | "NODE_ENV": "test" 42 | }, 43 | "console": "integratedTerminal", 44 | "sourceMaps": true 45 | } 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /packages/browser-tracker/test/index.test.ts: -------------------------------------------------------------------------------- 1 | import { Tracker as TrackerCore } from "@elastic/behavioral-analytics-tracker-core"; 2 | 3 | jest.mock("@elastic/behavioral-analytics-tracker-core", () => { 4 | return { 5 | Tracker: jest.fn().mockImplementation(() => { 6 | return { 7 | trackEvent: jest.fn(), 8 | trackPageView: jest.fn(), 9 | }; 10 | }), 11 | }; 12 | }); 13 | 14 | import Tracker from "../src/index"; 15 | 16 | // @ts-ignore 17 | Object.defineProperty(window, "addEventListener", { 18 | configurable: true, 19 | value: jest.fn(), 20 | }); 21 | 22 | describe("Tracker", () => { 23 | it("stores events until the tracker is created", () => { 24 | expect(TrackerCore).toBeDefined(); 25 | expect(TrackerCore).toBeCalledTimes(0); 26 | Tracker.trackPageView({}); 27 | Tracker.trackSearch({ search: { query: "test" } }); 28 | Tracker.trackSearchClick({ 29 | search: { query: "test" }, 30 | document: { id: "test", index: "test" }, 31 | }); 32 | 33 | const t = Tracker.createTracker({ 34 | apiKey: "ddd", 35 | collectionName: "test", 36 | endpoint: "http://localhost:3000", 37 | }); 38 | expect(TrackerCore).toBeCalledTimes(1); 39 | expect(TrackerCore).toHaveBeenCalledWith({ 40 | apiKey: "ddd", 41 | collectionName: "test", 42 | endpoint: "http://localhost:3000", 43 | }); 44 | 45 | expect(t.trackEvent).toBeCalledWith("search", { 46 | search: { query: "test" }, 47 | }); 48 | expect(t.trackEvent).toBeCalledWith("page_view", {}); 49 | expect(t.trackEvent).toBeCalledWith("search_click", { 50 | search: { query: "test" }, 51 | document: { id: "test", index: "test" }, 52 | }); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/core/test/util/cookies.test.ts: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { getCookie, setCookie } from "../../src/util/cookies"; 3 | 4 | describe("getCookie", () => { 5 | const setDocumentCookies = (cookies: string) => { 6 | Object.defineProperty(document, "cookie", { 7 | value: cookies, 8 | configurable: true, 9 | }); 10 | }; 11 | 12 | describe("when the cookie is not set", () => { 13 | describe("document.cookie is empty", () => { 14 | it("returns undefined", () => { 15 | setDocumentCookies(""); 16 | expect(getCookie("foo")).toBeUndefined(); 17 | }); 18 | }); 19 | 20 | describe.each(["bar=val", "bar=val1; baz=val2"])("docuent.cookie is %s", (cookies) => { 21 | it("returns undefined", () => { 22 | setDocumentCookies(cookies); 23 | expect(getCookie("foo")).toBeUndefined(); 24 | }); 25 | }); 26 | }); 27 | 28 | describe("when the cookie is set", () => { 29 | describe.each([ 30 | "foo=fooval", 31 | "foo=fooval; bar=bazval", 32 | "bar=bazval; foo=fooval", 33 | "baz=bazval; foo=fooval; bar=bazval", 34 | ])("docuent.cookie is %s", (cookies) => { 35 | it("returns undefined", () => { 36 | setDocumentCookies(cookies); 37 | expect(getCookie("foo")).toEqual("fooval"); 38 | }); 39 | }); 40 | }); 41 | }); 42 | 43 | describe("setCookie", () => { 44 | beforeEach(() => { 45 | Object.defineProperty(document, "cookie", { 46 | set: jest.fn().mockImplementation(), 47 | configurable: true, 48 | }); 49 | }); 50 | 51 | const documentCookieSetter = () => { 52 | return Object.getOwnPropertyDescriptor(document, "cookie")?.set; 53 | }; 54 | 55 | test("it set the cookie", () => { 56 | setCookie("foo", "value", moment("1984-01-18").toDate()); 57 | expect(documentCookieSetter()).toHaveBeenCalledWith( 58 | "foo=value; expires=Wed, 18 Jan 1984 00:00:00 GMT; path=/" 59 | ); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /packages/browser-tracker/src/index.ts: -------------------------------------------------------------------------------- 1 | import { PageViewInputProperties, Tracker } from "@elastic/behavioral-analytics-tracker-core"; 2 | import type { 3 | TrackerEventType, 4 | EventInputProperties, 5 | SearchClickEventInputProperties, 6 | SearchEventInputProperties, 7 | TrackerOptions, 8 | } from "@elastic/behavioral-analytics-tracker-core"; 9 | 10 | let tracker: Tracker | undefined; 11 | const pendingTrackerEvents: Array<[TrackerEventType, EventInputProperties]> = []; 12 | 13 | export interface BrowserTracker { 14 | createTracker: (options: TrackerOptions) => Tracker; 15 | trackPageView: (properties: PageViewInputProperties) => void; 16 | trackSearchClick: (properties: SearchClickEventInputProperties) => void; 17 | trackSearch: (properties: SearchEventInputProperties) => void; 18 | } 19 | 20 | const trackerShim: BrowserTracker = { 21 | createTracker: (options: TrackerOptions) => { 22 | tracker = new Tracker(options); 23 | pendingTrackerEvents.forEach(([eventType, properties]) => { 24 | tracker?.trackEvent(eventType, properties); 25 | }); 26 | return tracker; 27 | }, 28 | trackPageView: (properties: PageViewInputProperties) => { 29 | if (!tracker) { 30 | pendingTrackerEvents.push(["page_view", {}]); 31 | return; 32 | } 33 | tracker.trackPageView(properties); 34 | }, 35 | trackSearchClick: (properties: SearchClickEventInputProperties) => { 36 | if (!tracker) { 37 | pendingTrackerEvents.push(["search_click", properties]); 38 | return; 39 | } 40 | tracker.trackSearchClick(properties); 41 | }, 42 | trackSearch: (properties: SearchEventInputProperties) => { 43 | if (!tracker) { 44 | pendingTrackerEvents.push(["search", properties]); 45 | return; 46 | } 47 | tracker.trackSearch(properties); 48 | }, 49 | }; 50 | 51 | const trackPageView = () => { 52 | trackerShim.trackPageView({}); 53 | }; 54 | 55 | window.addEventListener("pageshow", trackPageView); 56 | 57 | if (window.history) { 58 | const pushState = window.history.pushState; 59 | window.history.pushState = (...args) => { 60 | window.dispatchEvent(new Event("ewt:pushstate")); 61 | return pushState.apply(window.history, args); 62 | }; 63 | window.addEventListener("ewt:pushstate", trackPageView); 64 | window.addEventListener("popstate", trackPageView); 65 | } else { 66 | window.addEventListener("hashchange", trackPageView); 67 | } 68 | 69 | export default trackerShim; 70 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Behavioral Analytics Tracker Mono Repo 2 | 3 | This repository contains the source code for the Behavioral Analytics Tracker. 4 | 5 | ## Packages 6 | 7 | ### JavaScript Tracker 8 | 9 | The JavaScript tracker is a library that allows you to track events from your web application. It is available as a [npm package](https://www.npmjs.com/package/@elastic/behavioral-analytics-javascript-tracker). 10 | 11 | You can find more information about the JavaScript tracker in the [README](packages/javascript-tracker/README.md). 12 | 13 | ### Browser Tracker 14 | 15 | The Browser tracker is a library that allows you to embed the Behavioral Analytics tracker in your web application. It is as a script that you can include in your web application. 16 | 17 | You can find more information about the Browser tracker in the [README](packages/browser-tracker/README.md). 18 | 19 | ## Versioning & Publishing 20 | 21 | All packages are versioned and published using changesets. For convenience, the following commands are available on the root: 22 | 23 | For every PR: 24 | 25 | - `yarn changeset`: Create a new changeset. Run every time you make a change to the packages. You will be prompted to select the packages that have changed and the type of change. A changeset file will be created in the `.changeset` folder. You can have multiple changesets in the same PR. 26 | 27 | For every release: 28 | 29 | - `yarn publish-packages`: Will run the build & test tasks. Then will adjust the package versions based on changesets and publish the packages to npm. 30 | 31 | ## Development 32 | 33 | All packages have unit tests. To run the tests, run the following command on the root of the repository: 34 | 35 | ```bash 36 | yarn test 37 | ``` 38 | 39 | This will run the tests for all packages. 40 | 41 | ### Linting 42 | 43 | All packages have linting rules. To run the linter, run the following command on the root of the repository: 44 | 45 | ```bash 46 | yarn lint 47 | ``` 48 | 49 | ### Integration tests 50 | 51 | We have cypress tests for the repository and a small test app. Follow the instructions in the [README](apps/sandbox/README.md) to run the tests. You must run the `yarn deploy` script in the `packages/browser-tracker` directory to copy the distribution to the test app. 52 | 53 | ## Updating the distribution 54 | 55 | The `browser-tracker` is distributed as a script that is part of the ent-search distribution. When the `browser-tracker` is updated, the distribution needs to be updated as well. To do so, run the following command: 56 | 57 | ```bash 58 | make deploy ent_search_dir= 59 | ``` 60 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/integration/browser-tracker.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-expect-in-promise */ 2 | 3 | describe("browser tracker", () => { 4 | it("should work", () => { 5 | cy.intercept( 6 | "https://my-browser-analytics-dsn.elastic.co/_application/analytics/test/event/*", 7 | { 8 | body: {}, 9 | } 10 | ).as("TrackerEvent"); 11 | 12 | cy.visit("http://localhost:3000/browser-tracker"); 13 | 14 | cy.wait(200); 15 | 16 | let userId = ""; 17 | let sessionId = ""; 18 | cy.getCookie("EA_UID").then(({ value }) => (userId = value)); 19 | cy.getCookie("EA_SID").then(({ value }) => (sessionId = value)); 20 | 21 | cy.wait("@TrackerEvent").then((interception) => { 22 | expect(interception.request.body).to.deep.contains({ 23 | user: { id: userId }, 24 | session: { id: sessionId }, 25 | page: { 26 | referrer: "", 27 | url: "http://localhost:3000/browser-tracker", 28 | title: "React App", 29 | }, 30 | }); 31 | expect(interception.request.url).to.contain("/event/page_view"); 32 | expect(interception.request.headers.authorization).to.deep.equal("Apikey cccc"); 33 | }); 34 | 35 | cy.get(".click-event").click(); 36 | 37 | cy.wait("@TrackerEvent").then((interception) => { 38 | expect(interception.request.body).to.deep.contains({ 39 | document: { id: "123", index: "products" }, 40 | search: { 41 | query: "", 42 | filters: {}, 43 | page: { current: 1, size: 10 }, 44 | results: { items: [], total_results: 10 }, 45 | sort: { 46 | name: "relevance", 47 | }, 48 | search_application: "website", 49 | }, 50 | user: { id: userId }, 51 | session: { id: sessionId }, 52 | page: { 53 | url: "http://localhost:3000/javascript-tracker", 54 | title: "my product detail", 55 | }, 56 | }); 57 | expect(interception.request.url).to.contain("/event/search_click"); 58 | }); 59 | 60 | cy.get(".search-event").click(); 61 | 62 | cy.wait("@TrackerEvent").then((interception) => { 63 | expect(interception.request.body).to.deep.contains({ 64 | search: { 65 | query: "laptop", 66 | filters: { 67 | brand: ["apple"], 68 | price: ["1000-2000"], 69 | categories: "tv", 70 | }, 71 | page: { current: 1, size: 10 }, 72 | results: { 73 | items: [ 74 | { 75 | document: { id: "123", index: "products" }, 76 | page: { url: "http://localhost:3000/javascript-tracker" }, 77 | }, 78 | ], 79 | total_results: 100, 80 | }, 81 | sort: { 82 | name: "relevance", 83 | }, 84 | search_application: "website", 85 | }, 86 | user: { id: userId }, 87 | session: { id: sessionId }, 88 | }); 89 | expect(interception.request.url).to.contain("/event/search"); 90 | }); 91 | 92 | cy.getCookie("EA_SID").should("exist"); 93 | cy.getCookie("EA_UID").should("exist"); 94 | cy.getCookie("EA_SESSION_SAMPLED").should("have.property", "value", "true"); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /apps/sandbox/cypress/integration/javascript-tracker.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable jest/valid-expect-in-promise */ 2 | 3 | describe("Javascript tracker", () => { 4 | it("should work", () => { 5 | cy.intercept( 6 | "https://my-browser-analytics-dsn.elastic.co/_application/analytics/test/event/*", 7 | { body: {} } 8 | ).as("TrackerEvent"); 9 | 10 | cy.visit("http://localhost:3000/javascript-tracker"); 11 | 12 | cy.wait(200); 13 | 14 | let userId = ""; 15 | let sessionId = ""; 16 | cy.getCookie("EA_UID").then(({ value }) => (userId = value)); 17 | cy.getCookie("EA_SID").then(({ value }) => (sessionId = value)); 18 | 19 | cy.wait("@TrackerEvent").then((interception) => { 20 | expect(interception.request.body).to.deep.contains({ 21 | user: { id: userId }, 22 | session: { id: sessionId }, 23 | page: { 24 | referrer: "", 25 | url: "http://localhost:3000/javascript-tracker", 26 | title: "React App", 27 | }, 28 | }); 29 | expect(interception.request.url).to.contain("/event/page_view"); 30 | expect(interception.request.headers.authorization).to.deep.equal("Apikey cccc"); 31 | }); 32 | 33 | cy.get(".click-event").click(); 34 | 35 | cy.wait("@TrackerEvent").then((interception) => { 36 | expect(interception.request.body).to.deep.contains({ 37 | document: { id: "123", index: "products" }, 38 | search: { 39 | query: "laptop", 40 | filters: { 41 | brand: ["apple"], 42 | price: ["1000-2000"], 43 | categories: "tv", 44 | }, 45 | page: { current: 1, size: 10 }, 46 | results: { items: [], total_results: 100 }, 47 | sort: { 48 | name: "relevance", 49 | }, 50 | search_application: "website", 51 | }, 52 | user: { id: userId }, 53 | session: { id: sessionId }, 54 | page: { 55 | url: "http://localhost:3000/javascript-tracker", 56 | title: "my product detail", 57 | }, 58 | }); 59 | expect(interception.request.url).to.contain("/event/search_click"); 60 | }); 61 | 62 | cy.get(".search-event").click(); 63 | 64 | cy.wait("@TrackerEvent").then((interception) => { 65 | console.log(interception.request.body); 66 | expect(interception.request.body).to.deep.contains({ 67 | search: { 68 | query: "laptop", 69 | filters: { brand: ["apple"] }, 70 | page: { current: 1, size: 10 }, 71 | results: { 72 | items: [ 73 | { 74 | document: { id: "123", index: "products" }, 75 | page: { url: "http://localhost:3000/javascript-tracker" }, 76 | }, 77 | ], 78 | total_results: 100, 79 | }, 80 | sort: { name: "relevance" }, 81 | search_application: "website", 82 | }, 83 | user: { id: userId }, 84 | session: { id: sessionId }, 85 | }); 86 | expect(interception.request.url).to.contain("/event/search"); 87 | }); 88 | 89 | cy.getCookie("EA_SID").should("exist"); 90 | cy.getCookie("EA_UID").should("exist"); 91 | cy.getCookie("EA_SESSION_SAMPLED").should("have.property", "value", "true"); 92 | }); 93 | }); 94 | -------------------------------------------------------------------------------- /packages/core/src/user_session_store.ts: -------------------------------------------------------------------------------- 1 | import { getCookie, setCookie } from "./util/cookies"; 2 | 3 | import { uuidv4 } from "./util/uuid"; 4 | 5 | const DEFAULT_SAMPLING = 1; 6 | const DEFAULT_USER_EXPIRATION_INTERVAL = 24 * 60 * 60 * 1000; 7 | const DEFAULT_SESSION_EXPIRATION_INTERVAL = 30 * 60 * 1000; 8 | const DEFAULT_SESSION_SAMPLED_INTERVAL = DEFAULT_SESSION_EXPIRATION_INTERVAL; 9 | 10 | const COOKIE_USER_NAME = "EA_UID"; 11 | const COOKIE_SESSION_NAME = "EA_SID"; 12 | const COOKIE_SESSION_SAMPLED_NAME = "EA_SESSION_SAMPLED"; 13 | 14 | type COOKIE_SAMPLED_VALUE = "true" | "false"; 15 | 16 | interface UserSessionOptions { 17 | user: { 18 | token?: string; 19 | lifetime?: number; 20 | }; 21 | session: { 22 | lifetime?: number; 23 | }; 24 | sampling?: number; 25 | } 26 | 27 | export class UserSessionStore { 28 | private userToken: string; 29 | private userTokenExpirationInterval: number; 30 | private sessionTokenExpirationInterval: number; 31 | private sampling: number; 32 | 33 | constructor(userSessionOptions: UserSessionOptions) { 34 | this.userToken = userSessionOptions.user.token || getCookie(COOKIE_USER_NAME) || uuidv4(); 35 | this.userTokenExpirationInterval = 36 | userSessionOptions.user.lifetime || DEFAULT_USER_EXPIRATION_INTERVAL; 37 | this.sessionTokenExpirationInterval = 38 | userSessionOptions.session.lifetime || DEFAULT_SESSION_EXPIRATION_INTERVAL; 39 | this.sampling = 40 | userSessionOptions.sampling === undefined ? DEFAULT_SAMPLING : userSessionOptions.sampling; 41 | 42 | if (!getCookie(COOKIE_SESSION_SAMPLED_NAME)) { 43 | this.updateSessionSampledExpire(); 44 | } else if (!this.isSessionSampled() && this.sampling === 1) { 45 | this.setSessionSampledExpire("true"); 46 | } 47 | 48 | if (this.userToken !== getCookie(COOKIE_USER_NAME)) { 49 | this.updateUserExpire(); 50 | } 51 | } 52 | 53 | getUserUuid() { 54 | let userId = getCookie(COOKIE_USER_NAME); 55 | 56 | if (!userId) { 57 | this.updateUserExpire(); 58 | userId = getCookie(COOKIE_USER_NAME); 59 | } 60 | 61 | return userId; 62 | } 63 | 64 | isSessionSampled() { 65 | return getCookie(COOKIE_SESSION_SAMPLED_NAME) == "true"; 66 | } 67 | 68 | updateSessionSampledExpire() { 69 | const sampled = 70 | getCookie(COOKIE_SESSION_SAMPLED_NAME) || (Math.random() <= this.sampling).toString(); 71 | 72 | this.setSessionSampledExpire(sampled as COOKIE_SAMPLED_VALUE); 73 | } 74 | 75 | private setSessionSampledExpire(sampled: COOKIE_SAMPLED_VALUE) { 76 | const expiresAt = new Date(); 77 | expiresAt.setMilliseconds(DEFAULT_SESSION_SAMPLED_INTERVAL); 78 | 79 | setCookie(COOKIE_SESSION_SAMPLED_NAME, sampled, expiresAt); 80 | } 81 | 82 | getSessionUuid() { 83 | return getCookie(COOKIE_SESSION_NAME); 84 | } 85 | 86 | updateSessionExpire() { 87 | const sessionId = getCookie(COOKIE_SESSION_NAME) || uuidv4(); 88 | 89 | const expiresAt = new Date(); 90 | expiresAt.setMilliseconds(this.sessionTokenExpirationInterval); 91 | setCookie(COOKIE_SESSION_NAME, sessionId, expiresAt); 92 | } 93 | 94 | private updateUserExpire() { 95 | const expiresAtDate = new Date(); 96 | expiresAtDate.setMilliseconds(this.userTokenExpirationInterval); 97 | 98 | setCookie(COOKIE_USER_NAME, this.userToken, expiresAtDate); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /packages/core/src/types.ts: -------------------------------------------------------------------------------- 1 | export type TrackerEventProperties = Record; 2 | 3 | export type DocumentAttribute = { 4 | index: string; 5 | id: string; 6 | }; 7 | 8 | export type ResultItemAttribute = { 9 | document: DocumentAttribute; 10 | page: { 11 | url: string; 12 | }; 13 | }; 14 | 15 | type SortAttribute = { 16 | name: string; 17 | direction?: "asc" | "desc"; 18 | }; 19 | 20 | export type SearchEventAttribute = { 21 | query: string; 22 | filters?: Record; 23 | search_application?: string; 24 | page?: { 25 | current: number; 26 | size: number; 27 | }; 28 | sort?: SortAttribute | SortAttribute[]; 29 | results?: { 30 | items: ResultItemAttribute[]; 31 | total_results: number; 32 | }; 33 | }; 34 | 35 | export type PageEventAttribute = { 36 | referrer?: string; 37 | url: string; 38 | title?: string; 39 | }; 40 | 41 | export type UserEventAttribute = { 42 | id: string; 43 | }; 44 | 45 | export type SessionEventAttribute = { 46 | id: string; 47 | }; 48 | 49 | export type EventProperties = { 50 | search?: SearchEventAttribute; 51 | page?: PageEventAttribute; 52 | document?: DocumentAttribute; 53 | session?: SessionEventAttribute; 54 | user?: UserEventAttribute; 55 | }; 56 | 57 | export type DataProvider = (eventType: TrackerEventType, event: EventProperties) => EventProperties; 58 | 59 | export type TrackerEventType = "page_view" | "search" | "search_click"; 60 | 61 | export interface TrackerOptions { 62 | /** 63 | * @description Required. collection name that you have setup. 64 | */ 65 | collectionName: string; 66 | /** 67 | * @description Required. The api key thats provided when setting up an analytics collection. See integration page to create one. 68 | */ 69 | apiKey: string; 70 | endpoint: string; 71 | dataProviders?: Record; 72 | user?: { 73 | /** 74 | * @description value of the user token 75 | * @default EA_UID 76 | */ 77 | token?: string | (() => string); 78 | /** 79 | * @description length of time for the user token 80 | * @default 24hrs 81 | */ 82 | lifetime?: number; 83 | }; 84 | session?: { 85 | /** 86 | * @description length of time for the session 87 | * @default 30 minutes 88 | */ 89 | lifetime?: number; 90 | }; 91 | /** 92 | * @description When debug is true, will add the querystring debug to the request. 93 | * @default false 94 | */ 95 | debug?: boolean; 96 | sampling?: number; 97 | } 98 | 99 | interface BaseSearchEventInputProperties { 100 | search: SearchEventAttribute; 101 | } 102 | 103 | export interface SearchEventInputProperties extends BaseSearchEventInputProperties {} 104 | 105 | interface SearchClickEventInputWithDocumentProperties extends BaseSearchEventInputProperties { 106 | document: DocumentAttribute; 107 | page?: PageEventAttribute; 108 | } 109 | 110 | interface SearchClickEventInputWithPageProperties extends BaseSearchEventInputProperties { 111 | document?: DocumentAttribute; 112 | page: PageEventAttribute; 113 | } 114 | 115 | export type SearchClickEventInputProperties = 116 | | SearchClickEventInputWithPageProperties 117 | | SearchClickEventInputWithDocumentProperties; 118 | 119 | export interface PageViewInputProperties { 120 | document?: DocumentAttribute; 121 | } 122 | 123 | export type EventInputProperties = 124 | | SearchClickEventInputProperties 125 | | SearchEventInputProperties 126 | | PageViewInputProperties; 127 | -------------------------------------------------------------------------------- /packages/core/src/tracker.ts: -------------------------------------------------------------------------------- 1 | import { DEFAULT_DATA_PROVIDERS } from "./dataproviders"; 2 | import { 3 | TrackerEventType, 4 | DataProvider, 5 | TrackerOptions, 6 | EventProperties, 7 | SearchEventInputProperties, 8 | EventInputProperties, 9 | SearchClickEventInputProperties, 10 | PageViewInputProperties, 11 | } from "./types"; 12 | import { UserSessionStore } from "./user_session_store"; 13 | 14 | export const processEvent = ( 15 | action: TrackerEventType, 16 | event: EventInputProperties, 17 | dataProviders: Record 18 | ) => { 19 | return Object.values(dataProviders).reduce( 20 | (props, dataProvider) => { 21 | return dataProvider(action, props); 22 | }, 23 | { ...event } 24 | ) as EventProperties; 25 | }; 26 | 27 | export class Tracker { 28 | private dataProviders: Record; 29 | private apiURL: string; 30 | private userSessionStore: UserSessionStore; 31 | private apiKey: string; 32 | private debug: boolean; 33 | 34 | constructor(options: TrackerOptions) { 35 | if (!options.endpoint || !options.collectionName || !options.apiKey) { 36 | throw new Error("Missing one or more of required options: endpoint, collectionName, apiKey"); 37 | } 38 | 39 | this.apiURL = `${options.endpoint}/_application/analytics/${options.collectionName}/event`; 40 | this.apiKey = options.apiKey; 41 | this.debug = options.debug || false; 42 | this.userSessionStore = new UserSessionStore({ 43 | user: { 44 | token: 45 | typeof options.user?.token === "function" ? options.user.token() : options.user?.token, 46 | lifetime: options.user?.lifetime, 47 | }, 48 | session: { 49 | lifetime: options.session?.lifetime, 50 | }, 51 | sampling: options.sampling, 52 | }); 53 | this.dataProviders = { 54 | ...DEFAULT_DATA_PROVIDERS, 55 | ...(options.dataProviders || {}), 56 | }; 57 | } 58 | 59 | trackEvent(action: TrackerEventType, event: EventInputProperties) { 60 | this.userSessionStore.updateSessionExpire(); 61 | this.userSessionStore.updateSessionSampledExpire(); 62 | 63 | if (!this.userSessionStore.isSessionSampled()) { 64 | return; 65 | } 66 | 67 | const userSessionAttributes = this.getUserSession(); 68 | const eventData = processEvent( 69 | action, 70 | { 71 | ...event, 72 | ...userSessionAttributes, 73 | }, 74 | this.dataProviders 75 | ); 76 | const encodedPayload = JSON.stringify(eventData); 77 | 78 | fetch(this.getEventTrackerURL(action), { 79 | method: "POST", 80 | headers: { 81 | "Content-Type": "application/json", 82 | Authorization: `Apikey ${this.apiKey}`, 83 | }, 84 | body: encodedPayload, 85 | }) 86 | .then((response) => { 87 | if (!response.ok) { 88 | return response.json(); 89 | } 90 | }) 91 | .then((body) => { 92 | const error = body?.error?.caused_by?.reason || body?.error?.reason; 93 | 94 | if (!!error) { 95 | throw new Error(error); 96 | } 97 | }) 98 | .catch((error) => { 99 | error.name = "TrackEventError"; 100 | console.error(error); 101 | }); 102 | } 103 | 104 | trackPageView(properties?: PageViewInputProperties) { 105 | this.trackEvent("page_view", properties || {}); 106 | } 107 | 108 | trackSearchClick(properties: SearchClickEventInputProperties) { 109 | this.trackEvent("search_click", properties); 110 | } 111 | 112 | trackSearch(properties: SearchEventInputProperties) { 113 | this.trackEvent("search", properties); 114 | } 115 | 116 | private getUserSession() { 117 | return { 118 | user: { 119 | id: this.userSessionStore.getUserUuid(), 120 | }, 121 | session: { 122 | id: this.userSessionStore.getSessionUuid(), 123 | }, 124 | }; 125 | } 126 | 127 | private getEventTrackerURL(action: TrackerEventType) { 128 | const queryString = this.debug ? "?debug=true" : ""; 129 | 130 | return `${this.apiURL}/${action}${queryString}`; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /apps/sandbox/public/behavioral-analytics-browser-tracker.js: -------------------------------------------------------------------------------- 1 | "use strict";var elasticAnalyticsDefault=(()=>{var d=Object.defineProperty;var _=Object.getOwnPropertyDescriptor;var I=Object.getOwnPropertyNames;var P=Object.prototype.hasOwnProperty;var A=(e,r)=>{for(var t in r)d(e,t,{get:r[t],enumerable:!0})},U=(e,r,t,i)=>{if(r&&typeof r=="object"||typeof r=="function")for(let s of I(r))!P.call(e,s)&&s!==t&&d(e,s,{get:()=>r[s],enumerable:!(i=_(r,s))||i.enumerable});return e};var x=e=>U(d({},"__esModule",{value:!0}),e);var M={};A(M,{default:()=>V});var y=(e,r)=>{let t=document.referrer||"";return e==="page_view"?{...r,page:{...r.page||{},referrer:t,url:window.location.href,title:document.title}}:r},L={pageAttributes:y};function o(e){var r;let i=("; "+document.cookie).split("; "+e+"=");if(i.length===2&&i[1])return(r=i.pop())==null?void 0:r.split(";").shift()}function h(e,r,t,i="/"){var s="expires="+t.toUTCString();document.cookie=e+"="+r+"; "+s+"; path="+i}function w(){return([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g,function(e){return(e^crypto.getRandomValues(new Uint8Array(1))[0]&15>>e/4).toString(16)})}var N=1,D=24*60*60*1e3,f=30*60*1e3,O=f,c="EA_UID",S="EA_SID",u="EA_SESSION_SAMPLED",C=class{constructor(e){this.userToken=e.user.token||o(c)||w(),this.userTokenExpirationInterval=e.user.lifetime||D,this.sessionTokenExpirationInterval=e.session.lifetime||f,this.sampling=e.sampling===void 0?N:e.sampling,o(u)?!this.isSessionSampled()&&this.sampling===1&&this.setSessionSampledExpire("true"):this.updateSessionSampledExpire(),this.userToken!==o(c)&&this.updateUserExpire()}getUserUuid(){let e=o(c);return e||(this.updateUserExpire(),e=o(c)),e}isSessionSampled(){return o(u)=="true"}updateSessionSampledExpire(){let e=o(u)||(Math.random()<=this.sampling).toString();this.setSessionSampledExpire(e)}setSessionSampledExpire(e){let r=new Date;r.setMilliseconds(O),h(u,e,r)}getSessionUuid(){return o(S)}updateSessionExpire(){let e=o(S)||w(),r=new Date;r.setMilliseconds(this.sessionTokenExpirationInterval),h(S,e,r)}updateUserExpire(){let e=new Date;e.setMilliseconds(this.userTokenExpirationInterval),h(c,this.userToken,e)}},R=(e,r,t)=>Object.values(t).reduce((i,s)=>s(e,i),{...r}),m=class{constructor(e){var r,t,i,s;if(!e.endpoint||!e.collectionName||!e.apiKey)throw new Error("Missing one or more of required options: endpoint, collectionName, apiKey");this.apiURL=`${e.endpoint}/_application/analytics/${e.collectionName}/event`,this.apiKey=e.apiKey,this.debug=e.debug||!1,this.userSessionStore=new C({user:{token:typeof((r=e.user)==null?void 0:r.token)=="function"?e.user.token():(t=e.user)==null?void 0:t.token,lifetime:(i=e.user)==null?void 0:i.lifetime},session:{lifetime:(s=e.session)==null?void 0:s.lifetime},sampling:e.sampling}),this.dataProviders={...L,...e.dataProviders||{}}}trackEvent(e,r){if(this.userSessionStore.updateSessionExpire(),this.userSessionStore.updateSessionSampledExpire(),!this.userSessionStore.isSessionSampled())return;let t=this.getUserSession(),i=R(e,{...r,...t},this.dataProviders),s=JSON.stringify(i);fetch(this.getEventTrackerURL(e),{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Apikey ${this.apiKey}`},body:s}).then(a=>{if(!a.ok)return a.json()}).then(a=>{var E,v,k;let g=((v=(E=a==null?void 0:a.error)==null?void 0:E.caused_by)==null?void 0:v.reason)||((k=a==null?void 0:a.error)==null?void 0:k.reason);if(g)throw new Error(g)}).catch(a=>{a.name="TrackEventError",console.error(a)})}trackPageView(e){this.trackEvent("page_view",e||{})}trackSearchClick(e){this.trackEvent("search_click",e)}trackSearch(e){this.trackEvent("search",e)}getUserSession(){return{user:{id:this.userSessionStore.getUserUuid()},session:{id:this.userSessionStore.getSessionUuid()}}}getEventTrackerURL(e){let r=this.debug?"?debug=true":"";return`${this.apiURL}/${e}${r}`}};var n,p=[],T={createTracker:e=>(n=new m(e),p.forEach(([r,t])=>{n==null||n.trackEvent(r,t)}),n),trackPageView:e=>{if(!n){p.push(["page_view",{}]);return}n.trackPageView(e)},trackSearchClick:e=>{if(!n){p.push(["search_click",e]);return}n.trackSearchClick(e)},trackSearch:e=>{if(!n){p.push(["search",e]);return}n.trackSearch(e)}},l=()=>{T.trackPageView({})};window.addEventListener("pageshow",l);if(window.history){let e=window.history.pushState;window.history.pushState=(...r)=>(window.dispatchEvent(new Event("ewt:pushstate")),e.apply(window.history,r)),window.addEventListener("ewt:pushstate",l),window.addEventListener("popstate",l)}else window.addEventListener("hashchange",l);var V=T;return x(M);})(); 2 | var elasticAnalytics = elasticAnalyticsDefault.default 3 | -------------------------------------------------------------------------------- /packages/javascript-tracker/test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createTracker, 3 | trackPageView, 4 | getTracker, 5 | trackSearch, 6 | trackSearchClick, 7 | } from "@elastic/behavioral-analytics-javascript-tracker"; 8 | import { Tracker } from "@elastic/behavioral-analytics-tracker-core"; 9 | 10 | describe("Integration", () => { 11 | beforeEach(() => { 12 | jest.clearAllMocks(); 13 | 14 | jest.spyOn(Tracker.prototype, "trackPageView"); 15 | jest.spyOn(Tracker.prototype, "trackSearch"); 16 | jest.spyOn(Tracker.prototype, "trackSearchClick"); 17 | 18 | // @ts-ignore 19 | global.fetch = jest.fn(() => 20 | Promise.resolve({ 21 | json: () => Promise.resolve(), 22 | }) 23 | ); 24 | }); 25 | 26 | test("exports", () => { 27 | expect(createTracker).toBeDefined(); 28 | expect(trackPageView).toBeDefined(); 29 | expect(trackSearch).toBeDefined(); 30 | expect(trackSearchClick).toBeDefined(); 31 | }); 32 | 33 | test("Throws error when not initialised", () => { 34 | expect(() => { 35 | trackPageView(); 36 | }).toThrowError("Behavioral Analytics: Tracker not initialized."); 37 | }); 38 | 39 | test("get tracker", () => { 40 | expect(() => { 41 | getTracker(); 42 | }).toThrow(); 43 | 44 | createTracker({ 45 | endpoint: "http://127.0.0.1:3000", 46 | apiKey: "sdddd", 47 | collectionName: "collection", 48 | }); 49 | 50 | expect(getTracker()).toBeDefined(); 51 | }); 52 | 53 | test("Dispatch track page view", async () => { 54 | createTracker({ 55 | endpoint: "http://127.0.0.1:4000", 56 | apiKey: "sdddd", 57 | collectionName: "collection", 58 | }); 59 | 60 | trackPageView(); 61 | 62 | expect(Tracker.prototype.trackPageView).toHaveBeenCalled(); 63 | expect(global.fetch).toHaveBeenCalledWith( 64 | "http://127.0.0.1:4000/_application/analytics/collection/event/page_view", 65 | expect.anything() 66 | ); 67 | }); 68 | 69 | test("Dispatch search event", async () => { 70 | createTracker({ 71 | endpoint: "http://127.0.0.1:4000", 72 | apiKey: "sdddd", 73 | collectionName: "collection", 74 | }); 75 | 76 | const mockProperties = { 77 | search: { 78 | query: "ddd", 79 | }, 80 | }; 81 | 82 | trackSearch(mockProperties); 83 | 84 | expect(Tracker.prototype.trackSearch).toHaveBeenCalledWith(mockProperties); 85 | expect(global.fetch).toHaveBeenCalledWith( 86 | "http://127.0.0.1:4000/_application/analytics/collection/event/search", 87 | expect.anything() 88 | ); 89 | }); 90 | 91 | test("Dispatch search click event", async () => { 92 | createTracker({ 93 | endpoint: "http://127.0.0.1:4000", 94 | apiKey: "sdddd", 95 | collectionName: "collection", 96 | }); 97 | 98 | const mockProperties = { 99 | search: { 100 | query: "ddd", 101 | }, 102 | document: { 103 | id: "1", 104 | index: "products", 105 | }, 106 | }; 107 | 108 | trackSearchClick(mockProperties); 109 | 110 | expect(Tracker.prototype.trackSearchClick).toHaveBeenCalledWith(mockProperties); 111 | expect(global.fetch).toHaveBeenCalledWith( 112 | "http://127.0.0.1:4000/_application/analytics/collection/event/search_click", 113 | expect.anything() 114 | ); 115 | }); 116 | 117 | test("overriding the session", async () => { 118 | const mockOverridenToken = "user-overriden-token"; 119 | createTracker({ 120 | endpoint: "http://127.0.0.1:4000", 121 | apiKey: "sdddd", 122 | collectionName: "collection", 123 | user: { 124 | token: mockOverridenToken, 125 | }, 126 | }); 127 | 128 | trackSearchClick({ 129 | search: { 130 | query: "ddd", 131 | }, 132 | document: { 133 | id: "1", 134 | index: "products", 135 | }, 136 | }); 137 | 138 | expect(Tracker.prototype.trackSearchClick).toHaveBeenCalled(); 139 | expect(global.fetch).toHaveBeenCalledWith( 140 | "http://127.0.0.1:4000/_application/analytics/collection/event/search_click", 141 | expect.objectContaining({ 142 | body: expect.stringContaining( 143 | `"user":${JSON.stringify({ 144 | id: mockOverridenToken, 145 | })}` 146 | ), 147 | }) 148 | ); 149 | }); 150 | }); 151 | -------------------------------------------------------------------------------- /packages/core/test/tracker.test.ts: -------------------------------------------------------------------------------- 1 | import { Tracker } from "../src/tracker"; 2 | import * as cookieUtils from "../src/util/cookies"; 3 | 4 | const flushPromises = () => 5 | new Promise((resolve) => jest.requireActual("timers").setImmediate(resolve)); 6 | 7 | describe("Tracker", () => { 8 | beforeEach(() => { 9 | global.fetch = jest.fn( 10 | () => 11 | Promise.resolve({ 12 | json: () => Promise.resolve(), 13 | }) as Promise 14 | ); 15 | }); 16 | 17 | const tracker = new Tracker({ 18 | apiKey: "key", 19 | endpoint: "http://localhost:3000", 20 | collectionName: "collection", 21 | dataProviders: { 22 | foo: (_, properties) => { 23 | return { ...properties, foo: "value" }; 24 | }, 25 | }, 26 | }); 27 | 28 | describe("Tracker instance", () => { 29 | test.each([ 30 | [ 31 | "apiKey", 32 | { 33 | apiKey: "", 34 | endpoint: "http://localhost:3000", 35 | collectionName: "collection", 36 | }, 37 | ], 38 | [ 39 | "endpoint", 40 | { 41 | apiKey: "key", 42 | endpoint: "", 43 | collectionName: "collection", 44 | }, 45 | ], 46 | [ 47 | "collectionName", 48 | { 49 | apiKey: "key", 50 | endpoint: "http://localhost:3000", 51 | collectionName: "", 52 | }, 53 | ], 54 | ])("throw error when %s is not provided", (_, options) => { 55 | try { 56 | new Tracker(options); 57 | } catch (e: unknown) { 58 | expect((e as Error).message).toEqual( 59 | "Missing one or more of required options: endpoint, collectionName, apiKey" 60 | ); 61 | } 62 | }); 63 | }); 64 | 65 | describe("trackEvent", () => { 66 | beforeEach(() => { 67 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("true"); 68 | }); 69 | 70 | test("send data at the right URL - page_view event", () => { 71 | tracker.trackPageView({}); 72 | 73 | expect(global.fetch).toHaveBeenCalledWith( 74 | "http://localhost:3000/_application/analytics/collection/event/page_view", 75 | expect.anything() 76 | ); 77 | }); 78 | 79 | test("send data at the right URL search event", () => { 80 | tracker.trackSearch({ 81 | search: { 82 | query: "query", 83 | }, 84 | }); 85 | 86 | expect(global.fetch).toHaveBeenCalledWith( 87 | "http://localhost:3000/_application/analytics/collection/event/search", 88 | expect.anything() 89 | ); 90 | }); 91 | 92 | test("send data at the right URL - search click event", () => { 93 | tracker.trackSearchClick({ 94 | search: { 95 | query: "query", 96 | }, 97 | page: { 98 | url: "http://my-url-to-navigate/", 99 | }, 100 | document: { 101 | id: "123", 102 | index: "1", 103 | }, 104 | }); 105 | 106 | expect(global.fetch).toHaveBeenCalledWith( 107 | "http://localhost:3000/_application/analytics/collection/event/search_click", 108 | expect.anything() 109 | ); 110 | }); 111 | 112 | test("applies data providers", () => { 113 | tracker.trackPageView({}); 114 | 115 | expect(global.fetch).toHaveBeenCalledWith( 116 | expect.anything(), 117 | expect.objectContaining({ 118 | body: expect.stringContaining('"foo":"value"'), 119 | headers: { 120 | Authorization: "Apikey key", 121 | "Content-Type": "application/json", 122 | }, 123 | }) 124 | ); 125 | }); 126 | 127 | describe("error handling", () => { 128 | test("when fetch is failed", async () => { 129 | const mockError = new Error("some error"); 130 | 131 | global.fetch = jest.fn(() => Promise.reject(mockError)); 132 | jest.spyOn(global.console, "error").mockImplementation(() => {}); 133 | 134 | tracker.trackPageView({}); 135 | 136 | await flushPromises(); 137 | 138 | expect(global.console.error).toHaveBeenCalledWith(mockError); 139 | }); 140 | 141 | test("when request returns 4xx, 5xx status code", async () => { 142 | const mockErrorReason = "some field is missing"; 143 | 144 | global.fetch = jest.fn(() => 145 | Promise.resolve({ 146 | ok: false, 147 | json: () => Promise.resolve({ error: { caused_by: { reason: mockErrorReason } } }), 148 | }) 149 | ) as jest.Mock>; 150 | jest.spyOn(global.console, "error").mockImplementation(() => {}); 151 | 152 | tracker.trackPageView({}); 153 | 154 | await flushPromises(); 155 | 156 | expect(global.console.error).toHaveBeenCalledWith(new Error(mockErrorReason)); 157 | }); 158 | }); 159 | }); 160 | 161 | describe("when session is not sampled", () => { 162 | beforeEach(() => { 163 | global.navigator.sendBeacon = jest.fn().mockImplementation(); 164 | // @ts-ignore 165 | window.XMLHttpRequest = jest.fn().mockImplementation(); 166 | 167 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("false"); 168 | }); 169 | 170 | describe("using XMLHttpRequest", () => { 171 | test("does not send data", () => { 172 | tracker.trackPageView({}); 173 | expect(XMLHttpRequest).not.toHaveBeenCalled(); 174 | }); 175 | }); 176 | }); 177 | }); 178 | -------------------------------------------------------------------------------- /packages/browser-tracker/README.md: -------------------------------------------------------------------------------- 1 | # Browser Tracker 2 | 3 | This package provides a tracker for the browser. Instructions for integrating the browser tracker into your site can be found in the Behavioural Analytics Collection view, under the **Integrate** tab. 4 | 5 | ## Usage 6 | 7 | Once you have integrated the tracker into your site, you can access the instance of tracker under the `window.elasticAnalytics` object. 8 | 9 | You must call the `createTracker` method before you can use the tracker. 10 | 11 | ```js 12 | window.elasticAnalytics.createTracker({ 13 | endpoint: "https://my-analytics-dsn.elastic.co", 14 | collectionName: "website", 15 | apiKey: "", 16 | }); 17 | ``` 18 | 19 | ## Token Fingerprints 20 | 21 | When `createTracker` is called, the tracker will store two fingerprints in the browser cookie: 22 | 23 | - **User Token** - a unique identifier for the user. Stored under `EA_UID` cookie. Default time length is 24 hours from the first time the user visits the site. 24 | - **Session Token** - a unique identifier for the session. Stored under `EA_SID` cookie. Time length is 30 minutes from the last time the user visits the site. 25 | 26 | These fingerprints are used to identify the user across sessions. 27 | 28 | ### Changing the User Token and time length 29 | 30 | You can change the User Token and time length by passing in the `token` and `lifetime` parameters to the `createTracker` method. 31 | 32 | ```js 33 | window.elasticAnalytics.createTracker({ 34 | user: { 35 | token: () => "my-user-token", 36 | lifetime: 24 * 60 * 60 * 1000, // 24 hours 37 | }, 38 | session: { 39 | lifetime: 30 * 60 * 1000, // 30 minutes 40 | }, 41 | }); 42 | ``` 43 | 44 | ### Introducing sampling 45 | 46 | You don't always want all sessions to be sent to your Elastic cluster. You can introduce session-based sampling by adding `sampling` parameter to the `createTracker` method. 47 | 48 | If sampling is set to 1 (default), all sessions will send events. If sampling is set to 0, no sessions will send events. 49 | 50 | ```js 51 | window.elasticAnalytics.createTracker({ 52 | // ... tracker settings 53 | sampling: 0.3, // 30% of sessions will send events to the server 54 | }); 55 | ``` 56 | 57 | ## Methods 58 | 59 | ### `createTracker` 60 | 61 | Creates a tracker instance. This method must be called before you can use the tracker. 62 | 63 | ```javascript 64 | createTracker(((options: TrackerOptions) = {})); 65 | ``` 66 | 67 | #### Example 68 | 69 | ```javascript 70 | window.elasticAnalytics.createTracker({ 71 | endpoint: "https://my-analytics-dsn.elastic.co", 72 | collectionName: "website", 73 | apiKey: "", 74 | user: { 75 | token: () => "my-user-token", 76 | lifetime: 24 * 60 * 60 * 1000, // 24 hours 77 | }, 78 | session: { 79 | lifetime: 30 * 60 * 1000, // 30 minutes 80 | }, 81 | }); 82 | ``` 83 | 84 | #### Parameters 85 | 86 | | Name | Type | Description | 87 | | ------- | -------------- | ---------------------------- | 88 | | options | TrackerOptions | The options for the tracker. | 89 | 90 | ### Dispatch Search Events 91 | 92 | These events are used to track the user's search behavior. You can dispatch these events by calling the `trackSearch` method. 93 | 94 | Below is an example of how you can dispatch a search event when a user searches for a query, for a hypothetical search API. 95 | 96 | ```typescript 97 | import { trackSearch } from "@elastic/behavioral-analytics-javascript-tracker"; 98 | 99 | const getSearchResults = async (query: string) => { 100 | const results = await api.getSearchResults(query); 101 | trackSearch({ 102 | search: { 103 | query: query, 104 | results: { 105 | // optional 106 | items: [], 107 | total_results: results.totalResults, 108 | }, 109 | }, 110 | }); 111 | }; 112 | ``` 113 | 114 | A full list of properties that can be passed to the `trackSearch` method below: 115 | 116 | ```javascript 117 | window.elasticAnalytics.trackSearch({ 118 | search: { 119 | query: "laptop", 120 | filters: [ 121 | // optional 122 | { field: "brand", value: ["apple"] }, 123 | ], 124 | page: { 125 | //optional 126 | current: 1, 127 | size: 10, 128 | }, 129 | results: { 130 | // optional 131 | items: [ 132 | { 133 | document: { 134 | id: "123", 135 | index: "products", 136 | }, 137 | page: { 138 | url: "http://my-website.com/products/123", 139 | }, 140 | }, 141 | ], 142 | total_results: 100, 143 | }, 144 | sort: { 145 | name: "relevance", 146 | }, 147 | search_application: "website", 148 | }, 149 | }); 150 | ``` 151 | 152 | ### Dispatch Search Click Events 153 | 154 | These events are used to track the user's search click behavior. Think of these events to track what the user is clicking on after they have performed a search. You can dispatch these events by calling the `trackSearchClick` method. 155 | 156 | Below is an example of how you can dispatch a search click event when a user clicks on a search result, for a hypothetical search API. 157 | 158 | ```typescript 159 | window.elasticAnalytics.trackSearchClick({ 160 | // document that they clicked on 161 | document: { id: "123", index: "products" }, 162 | // the query and results that they used to find this document 163 | search: { 164 | query: "laptop", 165 | filters: [ 166 | { field: "brand", value: ["apple"] }, 167 | { field: "price", value: ["1000-2000"] }, 168 | ], 169 | page: { 170 | current: 1, 171 | size: 10, 172 | }, 173 | results: { 174 | items: [ 175 | { 176 | document: { 177 | id: "123", 178 | index: "products", 179 | }, 180 | page: { 181 | url: "http://my-website.com/products/123", 182 | }, 183 | }, 184 | ], 185 | total_results: 100, 186 | }, 187 | sort: { 188 | name: "relevance", 189 | }, 190 | search_application: "website", 191 | }, 192 | }); 193 | ``` 194 | 195 | ## Types 196 | 197 | ### TrackerEventType 198 | 199 | Enum value for the type of event to track. Can be one of "search", "click", "pageview" values. 200 | 201 | ### TrackerOptions 202 | 203 | Options for the tracker. 204 | 205 | | Name | Type | Description | 206 | | ----------------------- | ---------------------- | --------------------------------------------------- | 207 | | userToken | () => string \| string | A string or a function that returns the user token. | 208 | | userTokenExpirationDate | number | The expiration date of the user token. | 209 | -------------------------------------------------------------------------------- /packages/core/test/userSessionStore.test.ts: -------------------------------------------------------------------------------- 1 | import { UserSessionStore } from "../src/user_session_store"; 2 | import * as cookieUtils from "../src/util/cookies"; 3 | 4 | describe("UserSessionStore", () => { 5 | beforeEach(() => { 6 | jest.clearAllMocks(); 7 | 8 | jest.useFakeTimers().setSystemTime(new Date("1984-01-18")); 9 | jest.spyOn(cookieUtils, "setCookie").mockReturnValue(undefined); 10 | jest 11 | .spyOn(cookieUtils, "getCookie") 12 | .mockImplementation((key) => (key === "EA_UID" ? "new-custom-user-token" : undefined)); 13 | }); 14 | 15 | describe("when passed userToken is different than in cookies", () => { 16 | beforeEach(() => { 17 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("generic-user-token"); 18 | }); 19 | 20 | test("updates token in cookies with the user's one", () => { 21 | new UserSessionStore({ 22 | user: { 23 | token: "new-custom-user-token", 24 | lifetime: 10000, 25 | }, 26 | session: {}, 27 | }); 28 | expect(cookieUtils.setCookie).toBeCalledWith( 29 | "EA_UID", 30 | "new-custom-user-token", 31 | expect.any(Date) 32 | ); 33 | }); 34 | }); 35 | 36 | describe("when sampling rate is passed", () => { 37 | describe("when EA_SESSION_SAMPLED cookie is not present", () => { 38 | test("sets EA_SESSION_SAMPLED cookie to true when random number is lower than sampling rate", () => { 39 | jest.spyOn(global.Math, "random").mockReturnValue(0.4); 40 | 41 | new UserSessionStore({ 42 | user: { 43 | token: "new-custom-user-token", 44 | lifetime: 10000, 45 | }, 46 | session: {}, 47 | sampling: 0.5, 48 | }); 49 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 50 | "EA_SESSION_SAMPLED", 51 | "true", 52 | expect.any(Date) 53 | ); 54 | }); 55 | 56 | test("sets EA_SESSION_SAMPLED cookie to false when random number is higher than sampling rate", () => { 57 | jest.spyOn(global.Math, "random").mockReturnValue(0.6); 58 | 59 | new UserSessionStore({ 60 | user: { 61 | token: "new-custom-user-token", 62 | lifetime: 10000, 63 | }, 64 | session: {}, 65 | sampling: 0.5, 66 | }); 67 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 68 | "EA_SESSION_SAMPLED", 69 | "false", 70 | expect.any(Date) 71 | ); 72 | }); 73 | }); 74 | 75 | describe("when EA_SESSION_SAMPLED cookie is present", () => { 76 | test("does not update EA_SESSION_SAMPLED cookie", () => { 77 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("true"); 78 | 79 | new UserSessionStore({ 80 | user: { 81 | token: "new-custom-user-token", 82 | lifetime: 10000, 83 | }, 84 | session: {}, 85 | sampling: 1, 86 | }); 87 | 88 | expect(cookieUtils.setCookie).not.toHaveBeenCalledWith( 89 | "EA_SESSION_SAMPLED", 90 | expect.anything(), 91 | expect.any(Date) 92 | ); 93 | }); 94 | 95 | test("update when EA_SESSION_SAMPLED cookie is false and sampled is 1", () => { 96 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("false"); 97 | 98 | new UserSessionStore({ 99 | user: { 100 | token: "new-custom-user-token", 101 | lifetime: 10000, 102 | }, 103 | session: {}, 104 | sampling: 1, 105 | }); 106 | 107 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 108 | "EA_SESSION_SAMPLED", 109 | "true", 110 | expect.any(Date) 111 | ); 112 | }); 113 | }); 114 | }); 115 | 116 | describe("when sampling rate is not passed", () => { 117 | test("sets EA_SESSION_SAMPLED cookie default to true", () => { 118 | new UserSessionStore({ 119 | user: { 120 | token: "new-custom-user-token", 121 | lifetime: 10000, 122 | }, 123 | session: {}, 124 | }); 125 | 126 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 127 | "EA_SESSION_SAMPLED", 128 | "true", 129 | expect.any(Date) 130 | ); 131 | }); 132 | }); 133 | 134 | describe("getUserUuid", () => { 135 | let userSessionStore: UserSessionStore; 136 | 137 | beforeEach(() => { 138 | userSessionStore = new UserSessionStore({ 139 | user: { 140 | token: "custom-user-token", 141 | lifetime: 10000, 142 | }, 143 | session: {}, 144 | }); 145 | }); 146 | 147 | describe("when EA_UID cookie is present", () => { 148 | beforeEach(() => { 149 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("custom-user-token"); 150 | }); 151 | 152 | test("returns the same EA_UID", () => { 153 | expect(userSessionStore.getUserUuid()).toEqual("custom-user-token"); 154 | }); 155 | 156 | test("doesn't update expirationDate", () => { 157 | jest.clearAllMocks(); 158 | 159 | userSessionStore.getUserUuid(); 160 | expect(cookieUtils.setCookie).not.toHaveBeenCalled(); 161 | }); 162 | }); 163 | 164 | describe("when EA_UID cookie is not present", () => { 165 | test("builds new user_uuid and saves to cookies with expirationDate", () => { 166 | const expirationDate = new Date(); 167 | expirationDate.setMilliseconds(10000); 168 | 169 | userSessionStore.getUserUuid(); 170 | 171 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 172 | "EA_UID", 173 | "custom-user-token", 174 | expirationDate 175 | ); 176 | }); 177 | }); 178 | }); 179 | 180 | describe("getSessionUuid", () => { 181 | let userSessionStore: UserSessionStore; 182 | 183 | beforeEach(() => { 184 | userSessionStore = new UserSessionStore({ 185 | user: { 186 | token: "custom-user-token", 187 | lifetime: 10000, 188 | }, 189 | session: {}, 190 | }); 191 | }); 192 | 193 | test("when EA_SID cookie is present returns session uuid from cookies", () => { 194 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("custom-user-token"); 195 | expect(userSessionStore.getSessionUuid()).toEqual("custom-user-token"); 196 | }); 197 | 198 | test("updates expiration date for cookie", () => { 199 | const expirationDate = new Date(); 200 | expirationDate.setMilliseconds(30 * 60 * 1000); 201 | 202 | userSessionStore.updateSessionExpire(); 203 | 204 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 205 | "EA_SID", 206 | expect.any(String), 207 | expirationDate 208 | ); 209 | }); 210 | }); 211 | 212 | describe("EA_SESSION_SAMPELD", () => { 213 | let userSessionStore: UserSessionStore; 214 | 215 | beforeEach(() => { 216 | userSessionStore = new UserSessionStore({ 217 | user: { 218 | token: "custom-user-token", 219 | lifetime: 10000, 220 | }, 221 | session: {}, 222 | }); 223 | }); 224 | 225 | test("when EA_SESSION_SAMPELD cookie is present returns session sampling param from cookies", () => { 226 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("true"); 227 | expect(userSessionStore.isSessionSampled()).toEqual(true); 228 | }); 229 | 230 | test("when EA_SESSION_SAMPELD cookie is not present returns session sampling param from cookies", () => { 231 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue(""); 232 | expect(userSessionStore.isSessionSampled()).toEqual(false); 233 | }); 234 | 235 | test("updates expiration date for cookie", () => { 236 | jest.spyOn(cookieUtils, "getCookie").mockReturnValue("true"); 237 | const expirationDate = new Date(); 238 | expirationDate.setMilliseconds(30 * 60 * 1000); 239 | 240 | userSessionStore.updateSessionSampledExpire(); 241 | 242 | expect(cookieUtils.setCookie).toHaveBeenCalledWith( 243 | "EA_SESSION_SAMPLED", 244 | "true", 245 | expirationDate 246 | ); 247 | }); 248 | }); 249 | }); 250 | -------------------------------------------------------------------------------- /apps/sandbox/public/index.global.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"sources":["../src/index.ts","../../core/src/dataproviders/event-type.ts","../../core/src/dataproviders/page-referrer.ts","../../core/src/dataproviders/page-url.ts","../../core/src/util/cookies.ts","../../core/src/util/uuid.ts","../../core/src/util/session.ts","../../core/src/dataproviders/user-uuid.ts","../../core/src/dataproviders/index.ts","../../core/src/tracker.ts","../src/util/script-attribute.ts"],"sourcesContent":["import { Tracker } from \"@elastic/behavioural-analytics-tracker-core\";\nimport { getScriptAttribute } from \"./util/script-attribute\";\n\nconst dsn = getScriptAttribute(\"data-dsn\");\nif (!dsn)\n throw new Error(\n \"Behavioural Analytics: Missing DSN. Please refer to the integration guide.\"\n );\nconst tracker = new Tracker({ dsn });\n\nconst trackPageView = () => tracker.trackPageView();\n\nwindow.addEventListener(\"pageshow\", trackPageView);\n\nif (window.history) {\n const pushState = window.history.pushState;\n window.history.pushState = (...args) => {\n window.dispatchEvent(new Event(\"ewt:pushstate\"));\n return pushState.apply(window.history, args);\n };\n window.addEventListener(\"ewt:pushstate\", trackPageView);\n window.addEventListener(\"popstate\", trackPageView);\n} else {\n window.addEventListener(\"hashchange\", trackPageView);\n}\n\nexport default tracker;\n","import { TrackerEventProperties, TrackerEventType } from \"../types\";\n\nexport default (\n eventType: TrackerEventType,\n properties: TrackerEventProperties\n) => {\n return { ...properties, event_type: eventType };\n};\n","import { TrackerEventProperties, TrackerEventType } from \"../types\";\n\nexport default (\n eventType: TrackerEventType,\n properties: TrackerEventProperties\n) => {\n if (eventType !== \"pageview\" || !document.referrer) {\n return properties;\n }\n\n return { ...properties, referrer: document.referrer };\n};\n","import { TrackerEventProperties, TrackerEventType } from \"../types\";\n\nexport default (\n eventType: TrackerEventType,\n properties: TrackerEventProperties\n) => {\n if (eventType !== \"pageview\") {\n return properties;\n }\n\n return { ...properties, url: window.location.href };\n};\n","export function getCookie(name: string) {\n const value = \"; \" + document.cookie;\n const parts = value.split(\"; \" + name + \"=\");\n\n if (parts.length === 2 && parts[1]) {\n return parts.pop()?.split(\";\").shift();\n }\n}\n\nexport function setCookie(\n cookieName: string,\n cookieValue: string,\n expiresAt: Date,\n path: string = \"/\"\n) {\n var expires = \"expires=\" + expiresAt.toUTCString();\n document.cookie =\n cookieName + \"=\" + cookieValue + \"; \" + expires + \"; path=\" + path;\n}\n","export function uuidv4() {\n // @ts-ignore\n return ([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, function (c) {\n return (\n c ^\n (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))\n ).toString(16);\n });\n}\n","import { getCookie, setCookie } from './cookies';\nimport { uuidv4 } from './uuid';\n\n\nexport function visitorId() {\n const visitorId = getCookie('EA_VID') || uuidv4();\n\n const expiresAt = new Date();\n expiresAt.setHours(23, 59, 59, 999);\n setCookie('EA_VID', visitorId, expiresAt);\n\n return visitorId;\n};\n","import { TrackerEventProperties, TrackerEventType } from \"../types\";\nimport { visitorId } from \"../util/session\";\n\nexport default (\n eventType: TrackerEventType,\n properties: TrackerEventProperties\n) => {\n return { ...properties, user_uuid: visitorId() };\n};\n","import eventType from './event-type';\nimport pageReferrer from './page-referrer';\nimport pageUrl from './page-url';\nimport userUuid from './user-uuid';\n\nexport {\n eventType,\n pageReferrer,\n pageUrl,\n userUuid\n};\n\nexport const DEFAULT_DATA_PROVIDERS = {\n eventType,\n pageReferrer,\n pageUrl,\n userUuid\n}\n","import { DEFAULT_DATA_PROVIDERS } from \"./dataproviders\";\nimport {\n TrackerEvent,\n TrackerEventProperties,\n TrackerEventType,\n DataProvider,\n TrackerOptions,\n} from \"./types\";\n\nexport const processEvent = (\n eventType: TrackerEventType,\n properties: TrackerEventProperties,\n dataProviders: Record\n) => {\n return Object.values(dataProviders).reduce(\n (props, dataProvider) => {\n return dataProvider(eventType, props);\n },\n { event_data: properties }\n ) as TrackerEvent;\n};\n\nexport class Tracker {\n private dataProviders: Record;\n private endpointURL: string;\n\n constructor(options: TrackerOptions) {\n this.endpointURL = options.dsn;\n this.dataProviders = {\n ...DEFAULT_DATA_PROVIDERS,\n ...(options.dataProviders || {}),\n };\n }\n\n trackEvent(\n eventType: TrackerEventType,\n properties: TrackerEventProperties = {}\n ) {\n const eventData = processEvent(eventType, properties, this.dataProviders);\n\n const encodedPayload = JSON.stringify(eventData);\n const eventTrackerURL = `${this.endpointURL}/events`;\n\n if (navigator.sendBeacon != null) {\n navigator.sendBeacon(eventTrackerURL, encodedPayload);\n } else {\n const xhr = new XMLHttpRequest();\n xhr.open(\"POST\", eventTrackerURL, true);\n xhr.setRequestHeader(\"Content-Type\", \"text/plain\");\n\n xhr.send(encodedPayload);\n }\n }\n\n trackPageView(properties?: TrackerEventProperties) {\n this.trackEvent(\"pageview\", properties);\n }\n}\n","export function getScriptAttribute(attributeName: string) {\n const scriptElement = document.currentScript;\n return scriptElement?.getAttribute(attributeName);\n}\n"],"mappings":"2cAAA,IAAAA,EAAA,GAAAC,EAAAD,EAAA,aAAAE,ICEA,IAAOC,EAAQ,CACbC,EACAC,KAEO,CAAE,GAAGA,EAAY,WAAYD,CAAU,GCJhD,IAAOE,EAAQ,CACbC,EACAC,IAEID,IAAc,YAAc,CAAC,SAAS,SACjCC,EAGF,CAAE,GAAGA,EAAY,SAAU,SAAS,QAAS,ECRtD,IAAOC,EAAQ,CACbC,EACAC,IAEID,IAAc,WACTC,EAGF,CAAE,GAAGA,EAAY,IAAK,OAAO,SAAS,IAAK,ECV7C,SAASC,EAAUC,EAAc,CAAxC,IAAAC,EAEE,IAAMC,GADQ,KAAO,SAAS,QACV,MAAM,KAAOF,EAAO,GAAG,EAE3C,GAAIE,EAAM,SAAW,GAAKA,EAAM,GAC9B,OAAOD,EAAAC,EAAM,IAAI,IAAV,YAAAD,EAAa,MAAM,KAAK,OAEnC,CAEO,SAASE,EACdC,EACAC,EACAC,EACAC,EAAe,IACf,CACA,IAAIC,EAAU,WAAaF,EAAU,YAAY,EACjD,SAAS,OACPF,EAAa,IAAMC,EAAc,KAAOG,EAAU,UAAYD,CAClE,CClBO,SAASE,GAAS,CAEvB,OAAQ,CAAC,GAAG,EAAI,KAAO,KAAO,KAAO,OAAO,QAAQ,SAAU,SAAUC,EAAG,CACzE,OACEA,EACC,OAAO,gBAAgB,IAAI,WAAW,CAAC,CAAC,EAAE,GAAM,IAAOA,EAAI,GAC5D,SAAS,EAAE,CACf,CAAC,CACH,CCJO,SAASC,GAAY,CAC1B,IAAMA,EAAYC,EAAU,QAAQ,GAAKC,EAAO,EAE1CC,EAAY,IAAI,KACtB,OAAAA,EAAU,SAAS,GAAI,GAAI,GAAI,GAAG,EAClCC,EAAU,SAAUJ,EAAWG,CAAS,EAEjCH,CACT,CCTA,IAAOK,EAAQ,CACbC,EACAC,KAEO,CAAE,GAAGA,EAAY,UAAWC,EAAU,CAAE,GCK1C,IAAMC,EAAyB,CACpC,UAAAC,EACA,aAAAC,EACA,QAAAC,EACA,SAAAC,CACF,ECRO,IAAMC,EAAe,CAC1BC,EACAC,EACAC,IAEO,OAAO,OAAOA,CAAa,EAAE,OAClC,CAACC,EAAOC,IACCA,EAAaJ,EAAWG,CAAK,EAEtC,CAAE,WAAYF,CAAW,CAC3B,EAGWI,EAAN,KAAc,CAInB,YAAYC,EAAyB,CACnC,KAAK,YAAcA,EAAQ,IAC3B,KAAK,cAAgB,CACnB,GAAGC,EACH,GAAID,EAAQ,eAAiB,CAAC,CAChC,CACF,CAEA,WACEN,EACAC,EAAqC,CAAC,EACtC,CACA,IAAMO,EAAYT,EAAaC,EAAWC,EAAY,KAAK,aAAa,EAElEQ,EAAiB,KAAK,UAAUD,CAAS,EACzCE,EAAkB,GAAG,KAAK,qBAEhC,GAAI,UAAU,YAAc,KAC1B,UAAU,WAAWA,EAAiBD,CAAc,MAC/C,CACL,IAAME,EAAM,IAAI,eAChBA,EAAI,KAAK,OAAQD,EAAiB,EAAI,EACtCC,EAAI,iBAAiB,eAAgB,YAAY,EAEjDA,EAAI,KAAKF,CAAc,CACzB,CACF,CAEA,cAAcR,EAAqC,CACjD,KAAK,WAAW,WAAYA,CAAU,CACxC,CACF,ECzDO,SAASW,EAAmBC,EAAuB,CACxD,IAAMC,EAAgB,SAAS,cAC/B,OAAOA,GAAA,YAAAA,EAAe,aAAaD,EACrC,CVAA,IAAME,EAAMC,EAAmB,UAAU,EACzC,GAAI,CAACD,EACH,MAAM,IAAI,MACR,4EACF,EACF,IAAME,EAAU,IAAIC,EAAQ,CAAE,IAAAH,CAAI,CAAC,EAE7BI,EAAgB,IAAMF,EAAQ,cAAc,EAElD,OAAO,iBAAiB,WAAYE,CAAa,EAEjD,GAAI,OAAO,QAAS,CAClB,IAAMC,EAAY,OAAO,QAAQ,UACjC,OAAO,QAAQ,UAAY,IAAIC,KAC7B,OAAO,cAAc,IAAI,MAAM,eAAe,CAAC,EACxCD,EAAU,MAAM,OAAO,QAASC,CAAI,GAE7C,OAAO,iBAAiB,gBAAiBF,CAAa,EACtD,OAAO,iBAAiB,WAAYA,CAAa,CACnD,MACE,OAAO,iBAAiB,aAAcA,CAAa,EAGrD,IAAOG,EAAQL","names":["src_exports","__export","src_default","event_type_default","eventType","properties","page_referrer_default","eventType","properties","page_url_default","eventType","properties","getCookie","name","_a","parts","setCookie","cookieName","cookieValue","expiresAt","path","expires","uuidv4","c","visitorId","getCookie","uuidv4","expiresAt","setCookie","user_uuid_default","eventType","properties","visitorId","DEFAULT_DATA_PROVIDERS","event_type_default","page_referrer_default","page_url_default","user_uuid_default","processEvent","eventType","properties","dataProviders","props","dataProvider","Tracker","options","DEFAULT_DATA_PROVIDERS","eventData","encodedPayload","eventTrackerURL","xhr","getScriptAttribute","attributeName","scriptElement","dsn","getScriptAttribute","tracker","Tracker","trackPageView","pushState","args","src_default"]} -------------------------------------------------------------------------------- /apps/sandbox/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { useEffect } from "react"; 3 | import * as ReactDOM from "react-dom/client"; 4 | import { 5 | createTracker, 6 | trackPageView, 7 | trackSearchClick, 8 | trackSearch, 9 | } from "@elastic/behavioral-analytics-javascript-tracker"; 10 | import type { BrowserTracker } from "@elastic/behavioral-analytics-browser-tracker"; 11 | import { createBrowserRouter, RouterProvider, Navigate } from "react-router-dom"; 12 | 13 | declare module window { 14 | const elasticAnalytics: BrowserTracker; 15 | } 16 | 17 | const JavascriptTracker = () => { 18 | useEffect(() => { 19 | createTracker({ 20 | apiKey: "cccc", 21 | collectionName: "test", 22 | endpoint: "https://my-browser-analytics-dsn.elastic.co", 23 | }); 24 | 25 | trackPageView(); 26 | }, []); 27 | 28 | const trackSearchHandler = () => { 29 | trackSearch({ 30 | search: { 31 | query: "laptop", 32 | filters: { 33 | brand: ["apple"], 34 | }, 35 | page: { 36 | //optional 37 | current: 1, 38 | size: 10, 39 | }, 40 | results: { 41 | // optional 42 | items: [ 43 | { 44 | document: { 45 | id: "123", 46 | index: "products", 47 | }, 48 | page: { 49 | url: "http://localhost:3000/javascript-tracker", 50 | }, 51 | }, 52 | ], 53 | total_results: 100, 54 | }, 55 | sort: { 56 | name: "relevance", 57 | }, 58 | search_application: "website", 59 | }, 60 | }); 61 | }; 62 | 63 | const trackSearchClickHandler = () => { 64 | trackSearchClick({ 65 | document: { id: "123", index: "products" }, 66 | search: { 67 | query: "laptop", 68 | filters: { 69 | brand: ["apple"], 70 | price: ["1000-2000"], 71 | categories: "tv", 72 | }, 73 | page: { 74 | current: 1, 75 | size: 10, 76 | }, 77 | results: { 78 | items: [], 79 | total_results: 100, 80 | }, 81 | sort: { 82 | name: "relevance", 83 | }, 84 | search_application: "website", 85 | }, 86 | page: { 87 | url: "http://localhost:3000/javascript-tracker", 88 | title: "my product detail", 89 | }, 90 | }); 91 | }; 92 | 93 | return ( 94 |
95 | 96 | click 97 | 98 |
99 | 100 | search 101 | 102 |
103 | javascript tracker 104 |
105 | ); 106 | }; 107 | 108 | const JavascriptTrackerWithSampling = () => { 109 | useEffect(() => { 110 | createTracker({ 111 | apiKey: "cccc", 112 | collectionName: "test", 113 | endpoint: "https://my-browser-analytics-dsn.elastic.co", 114 | sampling: 0, 115 | }); 116 | 117 | trackPageView(); 118 | }, []); 119 | 120 | const trackSearchHandler = () => { 121 | trackSearch({ 122 | search: { 123 | query: "laptop", 124 | filters: { 125 | brand: ["apple"], 126 | }, 127 | page: { 128 | //optional 129 | current: 1, 130 | size: 10, 131 | }, 132 | results: { 133 | // optional 134 | items: [ 135 | { 136 | document: { 137 | id: "123", 138 | index: "products", 139 | }, 140 | page: { 141 | url: "http://localhost:3000/javascript-tracker-with-sampling", 142 | }, 143 | }, 144 | ], 145 | total_results: 100, 146 | }, 147 | sort: { 148 | name: "relevance", 149 | }, 150 | search_application: "website", 151 | }, 152 | }); 153 | }; 154 | 155 | const trackSearchClickHandler = () => { 156 | trackSearchClick({ 157 | document: { id: "123", index: "products" }, 158 | search: { 159 | query: "laptop", 160 | filters: { 161 | brand: ["apple"], 162 | price: ["1000-2000"], 163 | categories: "tv", 164 | }, 165 | page: { 166 | current: 1, 167 | size: 10, 168 | }, 169 | results: { 170 | items: [], 171 | total_results: 100, 172 | }, 173 | sort: { 174 | name: "relevance", 175 | }, 176 | search_application: "website", 177 | }, 178 | page: { 179 | url: "http://localhost:3000/javascript-tracker", 180 | title: "my product detail", 181 | }, 182 | }); 183 | }; 184 | 185 | return ( 186 |
187 | 188 | click 189 | 190 |
191 | 192 | search 193 | 194 |
195 | javascript tracker with sampling 196 |
197 | ); 198 | }; 199 | 200 | const BrowserTrackerView = () => { 201 | useEffect(() => { 202 | window.elasticAnalytics.createTracker({ 203 | apiKey: "cccc", 204 | collectionName: "test", 205 | endpoint: "https://my-browser-analytics-dsn.elastic.co", 206 | }); 207 | }, []); 208 | 209 | return ( 210 |
211 | { 215 | e.preventDefault(); 216 | window.elasticAnalytics.trackSearchClick({ 217 | document: { 218 | id: "123", 219 | index: "products", 220 | }, 221 | page: { 222 | url: "http://localhost:3000/javascript-tracker", 223 | title: "my product detail", 224 | }, 225 | search: { 226 | query: "", 227 | filters: {}, 228 | page: { 229 | current: 1, 230 | size: 10, 231 | }, 232 | results: { 233 | items: [], 234 | total_results: 10, 235 | }, 236 | sort: { 237 | name: "relevance", 238 | }, 239 | search_application: "website", 240 | }, 241 | }); 242 | }} 243 | > 244 | document click 245 | 246 | { 250 | e.preventDefault(); 251 | window.elasticAnalytics.trackSearch({ 252 | search: { 253 | query: "laptop", 254 | filters: { 255 | brand: ["apple"], 256 | price: ["1000-2000"], 257 | categories: "tv", 258 | }, 259 | page: { 260 | current: 1, 261 | size: 10, 262 | }, 263 | results: { 264 | items: [ 265 | { 266 | document: { 267 | id: "123", 268 | index: "products", 269 | }, 270 | page: { 271 | url: "http://localhost:3000/javascript-tracker", 272 | }, 273 | }, 274 | ], 275 | total_results: 100, 276 | }, 277 | sort: { 278 | name: "relevance", 279 | }, 280 | search_application: "website", 281 | }, 282 | }); 283 | }} 284 | > 285 | search event 286 | 287 | browser tracker 288 |
289 | ); 290 | }; 291 | 292 | const BrowserTrackerWithSamplingView = () => { 293 | useEffect(() => { 294 | window.elasticAnalytics.createTracker({ 295 | apiKey: "cccc", 296 | collectionName: "test", 297 | endpoint: "https://my-browser-analytics-dsn.elastic.co", 298 | sampling: 0, 299 | }); 300 | }, []); 301 | 302 | return ( 303 |
304 | { 308 | e.preventDefault(); 309 | window.elasticAnalytics.trackSearchClick({ 310 | document: { 311 | id: "123", 312 | index: "products", 313 | }, 314 | page: { 315 | url: "http://localhost:3000/javascript-tracker-with-sampling", 316 | title: "my product detail", 317 | }, 318 | search: { 319 | query: "", 320 | filters: {}, 321 | page: { 322 | current: 1, 323 | size: 10, 324 | }, 325 | results: { 326 | items: [], 327 | total_results: 10, 328 | }, 329 | sort: { 330 | name: "relevance", 331 | }, 332 | search_application: "website", 333 | }, 334 | }); 335 | }} 336 | > 337 | document click 338 | 339 | { 343 | e.preventDefault(); 344 | window.elasticAnalytics.trackSearch({ 345 | search: { 346 | query: "laptop", 347 | filters: { 348 | brand: ["apple"], 349 | price: ["1000-2000"], 350 | categories: "tv", 351 | }, 352 | page: { 353 | current: 1, 354 | size: 10, 355 | }, 356 | results: { 357 | items: [ 358 | { 359 | document: { 360 | id: "123", 361 | index: "products", 362 | }, 363 | page: { 364 | url: "http://localhost:3000/javascript-tracker", 365 | }, 366 | }, 367 | ], 368 | total_results: 100, 369 | }, 370 | sort: { 371 | name: "relevance", 372 | }, 373 | search_application: "website", 374 | }, 375 | }); 376 | }} 377 | > 378 | search event 379 | 380 | browser tracker 381 |
382 | ); 383 | }; 384 | 385 | const router = createBrowserRouter([ 386 | { 387 | path: "/javascript-tracker", 388 | element: , 389 | }, 390 | { 391 | path: "/browser-tracker", 392 | element: , 393 | }, 394 | { 395 | path: "/javascript-tracker-with-sampling", 396 | element: , 397 | }, 398 | { 399 | path: "/browser-tracker-with-sampling", 400 | element: , 401 | }, 402 | { 403 | path: "/", 404 | element: , 405 | }, 406 | ]); 407 | 408 | const rootElement = document.getElementById("root"); 409 | if (!rootElement) throw new Error("Failed to find the root element"); 410 | const root = ReactDOM.createRoot(rootElement); 411 | root.render(); 412 | -------------------------------------------------------------------------------- /packages/javascript-tracker/README.md: -------------------------------------------------------------------------------- 1 | ## Javascript Tracker 2 | 3 | ### Installation 4 | 5 | You can install the tracker using npm or yarn: 6 | 7 | ```bash 8 | yarn add @elastic/behavioral-analytics-javascript-tracker@1 9 | ## OR 10 | npm install @elastic/behavioral-analytics-javascript-tracker@1 11 | ``` 12 | 13 | ## Usage 14 | 15 | Import the tracker in your application. 16 | 17 | ```javascript 18 | import { 19 | createTracker, 20 | trackPageView, 21 | trackSearch, 22 | trackSearchClick, 23 | } from "@elastic/behavioral-analytics-javascript-tracker"; 24 | ``` 25 | 26 | ### Initialize tracker 27 | 28 | use `createTracker` method to initialize the tracker with your DSN. You can find your DSN in the behavioral Analytics UI under Collection > Integrate. You will then be able to use the tracker to send events to behavioral Analytics. 29 | 30 | ```javascript 31 | import { 32 | createTracker, 33 | trackPageView, 34 | trackEvent, 35 | } from "@elastic/behavioral-analytics-javascript-tracker"; 36 | 37 | createTracker({ 38 | endpoint: "https://my-endpoint-url", 39 | collectionName: "website", 40 | apiKey: "", 41 | }); 42 | ``` 43 | 44 | ## Token Fingerprints 45 | 46 | When `createTracker` is called, the tracker will store two fingerprints in the browser cookie: 47 | 48 | - **User Token** - a unique identifier for the user. Stored under `EA_UID` cookie. Default Time length is 24 hours from the first time the user visits the site. 49 | - **Session Token** - a unique identifier for the session. Stored under `EA_SID` cookie. Time length is 30 minutes from the last time the user visits the site. 50 | 51 | These fingerprints are used to identify the user across sessions. 52 | 53 | ### Changing the User Token and time length 54 | 55 | You can change the User Token and time length by passing in the `token` and `lifetime` parameters to the `createTracker` method. 56 | 57 | You can also change the lifetime of the session token by passing in the `session.lifetime` parameter to the `createTracker` method. 58 | 59 | ```js 60 | createTracker({ 61 | user: { 62 | token: () => "my-user-token", // can be a string too 63 | lifetime: 24 * 60 * 60 * 1000, // 24 hours 64 | }, 65 | session: { 66 | lifetime: 30 * 60 * 1000, // 30 minutes 67 | }, 68 | } 69 | }); 70 | ``` 71 | 72 | ### Introducing sampling 73 | 74 | You don't always want all sessions to be sent to your Elastic cluster. You can introduce session-based sampling by adding `sampling` parameter to the `createTracker` method. 75 | 76 | If sampling is set to 1 (default), all sessions will send events. If sampling is set to 0, no sessions will send events. 77 | 78 | ```js 79 | createTracker({ 80 | // ... tracker settings 81 | sampling: 0.3, // 30% of sessions will send events to the server 82 | }); 83 | ``` 84 | 85 | ### Integration with Search UI (TODO) 86 | 87 | If you use [Search UI](github.com/elastic/search-ui), you can use the `AnalyticsPlugin` hook to automatically track search events. You can find more information about the `AnalyticsPlugin` [here](github.com/elastic/search-ui/blob/master/packages/search-analytics-plugin/README.md). 88 | 89 | ```javascript 90 | import AnalyticsPlugin from "@elastic/search-ui-analytics-plugin"; 91 | import { getTracker } from "@elastic/behavioral-analytics-javascript-tracker"; 92 | 93 | const searchUIConfig = { 94 | ... 95 | plugins: [AnalyticsPlugin({ 96 | client: getTracker() 97 | })], 98 | ... 99 | } 100 | ``` 101 | 102 | ### Dispatch Page View Events 103 | 104 | You can then use the tracker to track page views. 105 | 106 | ```javascript 107 | // track a page view 108 | 109 | const SearchPage = (props) => { 110 | useEffect(() => { 111 | trackPageView({ 112 | // optional 113 | document: { 114 | id: "search-page", 115 | index: "pages", 116 | }, 117 | }); 118 | }, []); 119 | 120 | return ( 121 |
122 |

Search Page

123 |
124 | ); 125 | }; 126 | ``` 127 | 128 | ### Dispatch Search Events 129 | 130 | These events are used to track the user's search behavior. You can dispatch these events by calling the `trackSearch` method. 131 | 132 | Below is an example of how you can dispatch a search event when a user searches for a query, for a hypothetical search API. 133 | 134 | ```typescript 135 | import { trackSearch } from "@elastic/behavioral-analytics-javascript-tracker"; 136 | 137 | const getSearchResults = async (query: string) => { 138 | const results = await api.getSearchResults(query); 139 | trackSearch({ 140 | search: { 141 | query: query, 142 | results: { 143 | // optional 144 | items: [], 145 | total_results: results.totalResults, 146 | }, 147 | }, 148 | }); 149 | }; 150 | ``` 151 | 152 | A full list of properties that can be passed to the `trackSearch` method below: 153 | 154 | ```javascript 155 | trackSearch({ 156 | search: { 157 | query: "laptop", 158 | filters: [ 159 | // optional 160 | { field: "brand", value: ["apple"] }, 161 | ], 162 | page: { 163 | //optional 164 | current: 1, 165 | size: 10, 166 | }, 167 | results: { 168 | // optional 169 | items: [ 170 | { 171 | document: { 172 | id: "123", 173 | index: "products", 174 | }, 175 | page: { 176 | url: "http://my-website.com/products/123", 177 | }, 178 | }, 179 | ], 180 | total_results: 100, 181 | }, 182 | sort: { 183 | name: "relevance", 184 | }, 185 | search_application: "website", 186 | }, 187 | }); 188 | ``` 189 | 190 | ### Dispatch Search Click Events 191 | 192 | These events are used to track the user's search click behavior. Think of these events to track what the user is clicking on after they have performed a search. You can dispatch these events by calling the `trackSearchClick` method. 193 | 194 | Below is an example of how you can dispatch a search click event when a user clicks on a search result, for a hypothetical search API. 195 | 196 | ```typescript 197 | import { trackSearchClick } from "@elastic/behavioral-analytics-javascript-tracker"; 198 | 199 | trackSearchClick({ 200 | // document that they clicked on 201 | document: { id: "123", index: "products" }, 202 | // the query and results that they used to find this document 203 | search: { 204 | query: "laptop", 205 | filters: [ 206 | { field: "brand", value: ["apple"] }, 207 | { field: "price", value: ["1000-2000"] }, 208 | ], 209 | page: { 210 | current: 1, 211 | size: 10, 212 | }, 213 | results: { 214 | items: [ 215 | { 216 | document: { 217 | id: "123", 218 | index: "products", 219 | }, 220 | page: { 221 | url: "http://my-website.com/products/123", 222 | }, 223 | }, 224 | ], 225 | total_results: 100, 226 | }, 227 | sort: { 228 | name: "relevance", 229 | }, 230 | search_application: "website", 231 | }, 232 | }); 233 | ``` 234 | 235 | ## Common Issues 236 | 237 | ### When I try to dispatch an event, I get the following error: `behavioral Analytics: Tracker not initialized.` 238 | 239 | This means that the tracker has not been initialized. You need to initialize the tracker before you can dispatch events. You can do this by calling the `createTracker` method. 240 | 241 | ## API Methods 242 | 243 | ### `createTracker` 244 | 245 | Initializes the tracker with the given configuration. This method must be called before you can use the tracker. 246 | 247 | ```javascript 248 | import { createTracker } from "@elastic/behavioral-analytics-javascript-tracker"; 249 | 250 | createTracker({ 251 | endpoint: "https://my-analytics-dsn.elastic.co", 252 | collectionName: "website", 253 | apiKey: "", 254 | }); 255 | ``` 256 | 257 | #### Example 258 | 259 | ```javascript 260 | createTracker({}); 261 | ``` 262 | 263 | #### Parameters 264 | 265 | | Name | Type | Description | 266 | | ------- | -------------- | ---------------------------- | 267 | | options | TrackerOptions | The options for the tracker. | 268 | 269 | #### TrackerOptions 270 | 271 | Options for the tracker. 272 | 273 | | Name | Type | Description | 274 | | -------------- | ---------------------- | ---------------------------------------------------------------------------------------------------------------- | 275 | | user.token | () => string \| string | A string or a function that returns the user token. | 276 | | user.lifetime | number | The expiration date of the user token. | 277 | | endpoint | string | The endpoint for events. You can find your endpoint in the behavioral Analytics UI under Collection > Integrate. | 278 | | collectionName | string | You can find your collection name in the behavioral Analytics UI under Collection > Integrate. | 279 | | apiKey | string | The apiKey for endpoint. You can find in the behavioral Analytics UI under Collection > Integrate. | 280 | 281 | ### `trackPageView` 282 | 283 | Tracks a page view event. 284 | 285 | ```javascript 286 | trackPageView(); 287 | ``` 288 | 289 | #### Example 290 | 291 | ```javascript 292 | import { trackPageView } from "@elastic/behavioral-analytics-javascript-tracker"; 293 | 294 | trackPageView({ 295 | document: { 296 | id: "123", 297 | index: "products", 298 | }, 299 | }); 300 | ``` 301 | 302 | #### Parameters 303 | 304 | | Name | Type | Description | 305 | | ---------- | ----------------------- | ---------------------------- | 306 | | properties | PageViewInputProperties | The properties of the event. | 307 | 308 | ### `trackSearch` 309 | 310 | Tracks a custom event. 311 | 312 | ```ts 313 | trackSearch( 314 | properties: SearchEventInputProperties 315 | ) 316 | ``` 317 | 318 | #### Example 319 | 320 | ```javascript 321 | import { trackSearch } from "@elastic/behavioral-analytics-javascript-tracker"; 322 | 323 | trackSearch({ 324 | search: { 325 | query: "laptop", 326 | filters: [ 327 | { field: "brand", value: ["apple"] }, 328 | { field: "price", value: ["1000-2000"] }, 329 | ], 330 | page: { 331 | current: 1, 332 | size: 10, 333 | }, 334 | results: { 335 | items: [ 336 | { 337 | document: { 338 | id: "123", 339 | index: "products", 340 | }, 341 | page: { 342 | url: "http://my-website.com/products/123", 343 | }, 344 | }, 345 | ], 346 | total_results: 100, 347 | }, 348 | sort: { 349 | name: "relevance", 350 | }, 351 | search_application: "website", 352 | }, 353 | }); 354 | ``` 355 | 356 | ### `trackSearchClick` 357 | 358 | Tracks a click thats related to a search event. Example of usage is when a user clicks on a result that came from a search query. 359 | 360 | Must have either a `document` or `page` property. Optimally both. 361 | 362 | ```ts 363 | trackSearchClick( 364 | properties: SearchClickEventInputProperties 365 | ) 366 | ``` 367 | 368 | #### Example 369 | 370 | ```javascript 371 | import { trackSearchClick } from "@elastic/behavioral-analytics-javascript-tracker"; 372 | 373 | trackSearchClick({ 374 | document: { 375 | id: "123", 376 | index: "products", 377 | }, 378 | page: { 379 | url: "http://my-website.com/products/123", 380 | title: "My Product", 381 | }, 382 | search: { 383 | query: "laptop", 384 | filters: [ 385 | { field: "brand", value: ["apple"] }, 386 | { field: "price", value: ["1000-2000"] }, 387 | ], 388 | page: { 389 | current: 1, 390 | size: 10, 391 | }, 392 | results: { 393 | items: [ 394 | { 395 | document: { 396 | id: "123", 397 | index: "products", 398 | }, 399 | page: { 400 | url: "http://my-website.com/products/123", 401 | }, 402 | }, 403 | ], 404 | total_results: 100, 405 | }, 406 | sort: { 407 | name: "relevance", 408 | }, 409 | search_application: "website", 410 | }, 411 | }); 412 | ``` 413 | 414 | #### Parameters 415 | 416 | | Name | Type | Description | 417 | | ---------- | ------------------------------- | ---------------------------- | 418 | | properties | SearchClickEventInputProperties | The properties of the event. | 419 | 420 | ### `getTracker` 421 | 422 | Returns the tracker instance. Useful when used to integrate with Search UI Analytics Plugin. 423 | 424 | ```javascript 425 | import { getTracker } from "@elastic/behavioral-analytics-javascript-tracker"; 426 | 427 | const tracker = getTracker(); 428 | ``` 429 | --------------------------------------------------------------------------------