-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add auto-tracking plugin (#570)
* feat: add auto-tracking plugin * build: update package version
- Loading branch information
1 parent
bfd0e08
commit 757032f
Showing
15 changed files
with
992 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# Change Log | ||
|
||
All notable changes to this project will be documented in this file. See | ||
[Conventional Commits](https://conventionalcommits.org) for commit guidelines. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,76 @@ | ||
<p align="center"> | ||
<a href="https://amplitude.com" target="_blank" align="center"> | ||
<img src="https://static.amplitude.com/lightning/46c85bfd91905de8047f1ee65c7c93d6fa9ee6ea/static/media/amplitude-logo-with-text.4fb9e463.svg" width="280"> | ||
</a> | ||
<br /> | ||
</p> | ||
|
||
# @amplitude/plugin-auto-tracking-browser | ||
|
||
Official Browser SDK plugin for auto-tracking. | ||
|
||
## Installation | ||
|
||
This package is published on NPM registry and is available to be installed using npm and yarn. | ||
|
||
```sh | ||
# npm | ||
npm install @amplitude/plugin-auto-tracking-browser | ||
|
||
# yarn | ||
yarn add @amplitude/plugin-auto-tracking-browser | ||
``` | ||
|
||
## Usage | ||
|
||
This plugin works on top of the Amplitude Browser SDK, generating auto-tracked events and sending to Amplitude. | ||
|
||
To use this plugin, you need to install `@amplitude/analytics-browser` version `v1.9.1` or later. | ||
|
||
### 1. Import Amplitude packages | ||
|
||
* `@amplitude/analytics-browser` | ||
* `@amplitude/plugin-auto-tracking-browser` | ||
|
||
```typescript | ||
import * as amplitude from '@amplitude/analytics-browser'; | ||
import { autoTrackingPlugin } from '@amplitude/plugin-auto-tracking-browser'; | ||
``` | ||
|
||
### 2. Instantiate the plugin | ||
|
||
The plugin accepts 1 optional parameter, which is an `Object` to configure the allowed tracking options. | ||
|
||
```typescript | ||
const plugin = autoTrackingPlugin({ | ||
cssSelectorAllowlist: ['.amp-auto-tracking', '[amp-auto-tracking]'], | ||
tagAllowlist: ['button', 'a'], | ||
}); | ||
``` | ||
|
||
Examples: | ||
- The above `cssSelectorAllowlist` will only allow tracking elements like: | ||
- `<button amp-auto-tracking>Click</button>` | ||
- `<a class="amp-auto-tracking">Link</a>` | ||
- The above `tagAllowlist` will only allow `button` and `a` tags to be tracked. | ||
|
||
Note `ingestionMetadata` is for internal use only, you don't need to provide it. | ||
|
||
#### Options | ||
|
||
|Name|Type|Default|Description| | ||
|-|-|-|-| | ||
|`cssSelectorAllowlist`|`string[]`|`undefined`| When provided, only allow elements matching any selector to be tracked. | | ||
|`tagAllowlist`|`string[]`|`['a', 'button', 'input', 'select', 'textarea', 'label']`| Only allow elements with tag in this list to be tracked. | | ||
|
||
### 3. Install plugin to Amplitude SDK | ||
|
||
```typescript | ||
amplitude.add(plugin); | ||
``` | ||
|
||
### 4. Initialize Amplitude SDK | ||
|
||
```typescript | ||
amplitude.init('API_KEY'); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
const baseConfig = require('../../jest.config.js'); | ||
const package = require('./package'); | ||
|
||
module.exports = { | ||
...baseConfig, | ||
displayName: package.name, | ||
rootDir: '.', | ||
testEnvironment: 'jsdom', | ||
coveragePathIgnorePatterns: ['index.ts'], | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,57 @@ | ||
{ | ||
"name": "@amplitude/plugin-auto-tracking-browser", | ||
"version": "0.0.0", | ||
"description": "", | ||
"author": "Amplitude Inc", | ||
"homepage": "https://github.com/amplitude/Amplitude-TypeScript", | ||
"license": "MIT", | ||
"main": "lib/cjs/index.js", | ||
"module": "lib/esm/index.js", | ||
"types": "lib/esm/index.d.ts", | ||
"sideEffects": false, | ||
"publishConfig": { | ||
"access": "public", | ||
"tag": "latest" | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/amplitude/Amplitude-TypeScript.git" | ||
}, | ||
"scripts": { | ||
"build": "yarn bundle && yarn build:es5 && yarn build:esm", | ||
"bundle": "rollup --config rollup.config.js", | ||
"build:es5": "tsc -p ./tsconfig.es5.json", | ||
"build:esm": "tsc -p ./tsconfig.esm.json", | ||
"clean": "rimraf node_modules lib coverage", | ||
"fix": "yarn fix:eslint & yarn fix:prettier", | ||
"fix:eslint": "eslint '{src,test}/**/*.ts' --fix", | ||
"fix:prettier": "prettier --write \"{src,test}/**/*.ts\"", | ||
"lint": "yarn lint:eslint & yarn lint:prettier", | ||
"lint:eslint": "eslint '{src,test}/**/*.ts'", | ||
"lint:prettier": "prettier --check \"{src,test}/**/*.ts\"", | ||
"publish": "node ../../scripts/publish/upload-to-s3.js", | ||
"test": "jest", | ||
"typecheck": "tsc -p ./tsconfig.json" | ||
}, | ||
"bugs": { | ||
"url": "https://github.com/amplitude/Amplitude-TypeScript/issues" | ||
}, | ||
"dependencies": { | ||
"@amplitude/analytics-client-common": ">=1 <3", | ||
"@amplitude/analytics-types": ">=1 <3", | ||
"tslib": "^2.4.1" | ||
}, | ||
"devDependencies": { | ||
"@amplitude/analytics-browser": "^2.1.2", | ||
"@rollup/plugin-commonjs": "^23.0.4", | ||
"@rollup/plugin-node-resolve": "^15.0.1", | ||
"@rollup/plugin-typescript": "^10.0.1", | ||
"rollup": "^2.79.1", | ||
"rollup-plugin-execute": "^1.1.1", | ||
"rollup-plugin-gzip": "^3.1.0", | ||
"rollup-plugin-terser": "^7.0.2" | ||
}, | ||
"files": [ | ||
"lib" | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { iife, umd } from '../../scripts/build/rollup.config'; | ||
|
||
iife.input = umd.input; | ||
iife.output.name = 'amplitudeAutoTrackingPlugin'; | ||
|
||
export default [umd, iife]; |
191 changes: 191 additions & 0 deletions
191
packages/plugin-auto-tracking-browser/src/auto-tracking-plugin.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
/* eslint-disable no-restricted-globals */ | ||
import { BrowserClient, BrowserConfig, EnrichmentPlugin, IngestionMetadata } from '@amplitude/analytics-types'; | ||
import * as constants from './constants'; | ||
import { getText } from './helpers'; | ||
|
||
type BrowserEnrichmentPlugin = EnrichmentPlugin<BrowserClient, BrowserConfig>; | ||
type ActionType = 'click' | 'change'; | ||
|
||
const DEFAULT_TAG_ALLOWLIST = ['a', 'button', 'input', 'select', 'textarea', 'label']; | ||
|
||
interface EventListener { | ||
element: Element; | ||
type: ActionType; | ||
handler: () => void; | ||
} | ||
|
||
interface Options { | ||
cssSelectorAllowlist?: string[]; | ||
tagAllowlist?: string[]; | ||
ingestionMetadata?: IngestionMetadata; // Should avoid overriding this if unplanned to do so, this is not available in the charts. | ||
} | ||
|
||
export const autoTrackingPlugin = (options: Options = {}): BrowserEnrichmentPlugin => { | ||
const { tagAllowlist = DEFAULT_TAG_ALLOWLIST, cssSelectorAllowlist, ingestionMetadata } = options; | ||
const name = constants.PLUGIN_NAME; | ||
const type = 'enrichment'; | ||
|
||
let observer: MutationObserver | undefined; | ||
let eventListeners: EventListener[] = []; | ||
|
||
const addEventListener = (element: Element, type: ActionType, handler: () => void) => { | ||
element.addEventListener(type, handler); | ||
eventListeners.push({ | ||
element: element, | ||
type: type, | ||
handler: handler, | ||
}); | ||
}; | ||
|
||
const removeEventListeners = () => { | ||
eventListeners.forEach((_ref) => { | ||
const element = _ref.element, | ||
type = _ref.type, | ||
handler = _ref.handler; | ||
/* istanbul ignore next */ | ||
element?.removeEventListener(type, handler); | ||
}); | ||
eventListeners = []; | ||
}; | ||
|
||
const shouldTrackEvent = (actionType: ActionType, element: Element) => { | ||
/* istanbul ignore if */ | ||
if (!element) { | ||
return false; | ||
} | ||
/* istanbul ignore next */ | ||
const elementType = (element as HTMLInputElement)?.type || ''; | ||
if (typeof elementType === 'string') { | ||
switch (elementType.toLowerCase()) { | ||
case 'hidden': | ||
return false; | ||
case 'password': | ||
return false; | ||
} | ||
} | ||
const tag = element.tagName.toLowerCase(); | ||
/* istanbul ignore if */ | ||
if (!tagAllowlist.includes(tag)) { | ||
return false; | ||
} | ||
if (cssSelectorAllowlist) { | ||
const hasMatchAnyAllowedSelector = cssSelectorAllowlist.some((selector) => element.matches(selector)); | ||
if (!hasMatchAnyAllowedSelector) { | ||
return false; | ||
} | ||
} | ||
switch (tag) { | ||
case 'input': | ||
case 'select': | ||
case 'textarea': | ||
return actionType === 'change' || actionType === 'click'; | ||
default: { | ||
/* istanbul ignore next */ | ||
const computedStyle = window?.getComputedStyle?.(element); | ||
/* istanbul ignore next */ | ||
if (computedStyle && computedStyle.getPropertyValue('cursor') === 'pointer' && actionType === 'click') { | ||
return true; | ||
} | ||
return actionType === 'click'; | ||
} | ||
} | ||
}; | ||
|
||
const getEventProperties = (actionType: ActionType, element: Element) => { | ||
const tag = element.tagName.toLowerCase(); | ||
/* istanbul ignore next */ | ||
const rect = | ||
typeof element.getBoundingClientRect === 'function' ? element.getBoundingClientRect() : { left: null, top: null }; | ||
/* istanbul ignore next */ | ||
const properties: Record<string, any> = { | ||
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_ID]: element.id, | ||
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_CLASS]: element.className, | ||
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_TAG]: tag, | ||
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_TEXT]: getText(element), | ||
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_LEFT]: rect.left == null ? null : Math.round(rect.left), | ||
[constants.AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_TOP]: rect.top == null ? null : Math.round(rect.top), | ||
[constants.AMPLITUDE_EVENT_PROP_PAGE_URL]: window.location.href.split('?')[0], | ||
[constants.AMPLITUDE_EVENT_PROP_PAGE_TITLE]: (typeof document !== 'undefined' && document.title) || '', | ||
[constants.AMPLITUDE_EVENT_PROP_VIEWPORT_HEIGHT]: window.innerHeight, | ||
[constants.AMPLITUDE_EVENT_PROP_VIEWPORT_WIDTH]: window.innerWidth, | ||
}; | ||
if (tag === 'a' && actionType === 'click' && element instanceof HTMLAnchorElement) { | ||
properties[constants.AMPLITUDE_EVENT_PROP_ELEMENT_HREF] = element.href; | ||
} | ||
return properties; | ||
}; | ||
|
||
const setup: BrowserEnrichmentPlugin['setup'] = async (config, amplitude) => { | ||
if (!amplitude) { | ||
/* istanbul ignore next */ | ||
config?.loggerProvider?.warn( | ||
`${name} plugin requires a later version of @amplitude/analytics-browser. Events are not tracked.`, | ||
); | ||
return; | ||
} | ||
/* istanbul ignore if */ | ||
if (typeof document === 'undefined') { | ||
return; | ||
} | ||
const addListener = (el: Element) => { | ||
if (shouldTrackEvent('click', el)) { | ||
addEventListener(el, 'click', () => { | ||
/* istanbul ignore next */ | ||
amplitude?.track(constants.AMPLITUDE_ELEMENT_CLICKED_EVENT, getEventProperties('click', el)); | ||
}); | ||
} | ||
if (shouldTrackEvent('change', el)) { | ||
addEventListener(el, 'change', () => { | ||
/* istanbul ignore next */ | ||
amplitude?.track(constants.AMPLITUDE_ELEMENT_CHANGED_EVENT, getEventProperties('change', el)); | ||
}); | ||
} | ||
}; | ||
const allElements = Array.from(document.body.querySelectorAll(tagAllowlist.join(','))); | ||
allElements.forEach(addListener); | ||
if (typeof MutationObserver !== 'undefined') { | ||
observer = new MutationObserver((mutations) => { | ||
mutations.forEach((mutation) => { | ||
mutation.addedNodes.forEach((node: Node) => { | ||
addListener(node as Element); | ||
if ('querySelectorAll' in node && typeof node.querySelectorAll === 'function') { | ||
Array.from(node.querySelectorAll(tagAllowlist.join(',')) as HTMLElement[]).map(addListener); | ||
} | ||
}); | ||
}); | ||
}); | ||
observer.observe(document.body, { | ||
subtree: true, | ||
childList: true, | ||
}); | ||
} | ||
/* istanbul ignore next */ | ||
config?.loggerProvider?.log(`${name} has been successfully added.`); | ||
}; | ||
|
||
const execute: BrowserEnrichmentPlugin['execute'] = async (event) => { | ||
const { sourceName, sourceVersion } = ingestionMetadata || {}; | ||
if (sourceName && sourceVersion) { | ||
event.ingestion_metadata = { | ||
source_name: `${constants.INGESTION_METADATA_SOURCE_NAME_PREFIX}${sourceName}`, // Make sure the source name is prefixed with the correct context. | ||
source_version: sourceVersion, | ||
}; | ||
} | ||
return event; | ||
}; | ||
|
||
const teardown = async () => { | ||
if (observer) { | ||
observer.disconnect(); | ||
} | ||
removeEventListeners(); | ||
}; | ||
|
||
return { | ||
name, | ||
type, | ||
setup, | ||
execute, | ||
teardown, | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
export const PLUGIN_NAME = '@amplitude/plugin-auto-tracking-browser'; | ||
|
||
export const AMPLITUDE_ELEMENT_CLICKED_EVENT = '[Amplitude] Element Clicked'; | ||
export const AMPLITUDE_ELEMENT_CHANGED_EVENT = '[Amplitude] Element Changed'; | ||
|
||
export const AMPLITUDE_EVENT_PROP_ELEMENT_ID = '[Amplitude] Element ID'; | ||
export const AMPLITUDE_EVENT_PROP_ELEMENT_CLASS = '[Amplitude] Element Class'; | ||
export const AMPLITUDE_EVENT_PROP_ELEMENT_TAG = '[Amplitude] Element Tag'; | ||
export const AMPLITUDE_EVENT_PROP_ELEMENT_TEXT = '[Amplitude] Element Text'; | ||
export const AMPLITUDE_EVENT_PROP_ELEMENT_HREF = '[Amplitude] Element Href'; | ||
export const AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_LEFT = '[Amplitude] Element Position Left'; | ||
export const AMPLITUDE_EVENT_PROP_ELEMENT_POSITION_TOP = '[Amplitude] Element Position Top'; | ||
export const AMPLITUDE_EVENT_PROP_PAGE_URL = '[Amplitude] Page URL'; | ||
export const AMPLITUDE_EVENT_PROP_PAGE_TITLE = '[Amplitude] Page Title'; | ||
export const AMPLITUDE_EVENT_PROP_VIEWPORT_HEIGHT = '[Amplitude] Viewport Height'; | ||
export const AMPLITUDE_EVENT_PROP_VIEWPORT_WIDTH = '[Amplitude] Viewport Width'; | ||
|
||
export const INGESTION_METADATA_SOURCE_NAME_PREFIX = 'browser-typescript-'; |
Oops, something went wrong.