Skip to content

Commit

Permalink
feat: add auto-tracking plugin (#570)
Browse files Browse the repository at this point in the history
* feat: add auto-tracking plugin

* build: update package version
  • Loading branch information
liuyang1520 authored Aug 31, 2023
1 parent bfd0e08 commit 757032f
Show file tree
Hide file tree
Showing 15 changed files with 992 additions and 1 deletion.
4 changes: 4 additions & 0 deletions packages/plugin-auto-tracking-browser/CHANGELOG.md
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.
76 changes: 76 additions & 0 deletions packages/plugin-auto-tracking-browser/README.md
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');
```
10 changes: 10 additions & 0 deletions packages/plugin-auto-tracking-browser/jest.config.js
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'],
};
57 changes: 57 additions & 0 deletions packages/plugin-auto-tracking-browser/package.json
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"
]
}
6 changes: 6 additions & 0 deletions packages/plugin-auto-tracking-browser/rollup.config.js
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 packages/plugin-auto-tracking-browser/src/auto-tracking-plugin.ts
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,
};
};
18 changes: 18 additions & 0 deletions packages/plugin-auto-tracking-browser/src/constants.ts
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-';
Loading

0 comments on commit 757032f

Please sign in to comment.