From c29083966707eba3b9aad0ea77d9348bca472b8d Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 25 Oct 2024 10:04:50 +1100 Subject: [PATCH 01/30] Initial element tracking plugin --- .../browser-plugin-element-tracking/LICENSE | 29 + .../browser-plugin-element-tracking/README.md | 59 ++ .../index.html | 110 +++ .../jest.config.js | 5 + .../package.json | 53 ++ .../rollup.config.js | 67 ++ .../src/api.ts | 323 ++++++++ .../src/configuration.ts | 157 ++++ .../src/data.ts | 135 ++++ .../src/index.ts | 1 + .../src/schemata.ts | 91 +++ .../src/types.ts | 13 + .../test/api.test.ts | 749 ++++++++++++++++++ .../test/pingInterval.test.ts | 71 ++ .../test/sessionStats.test.ts | 217 +++++ .../tsconfig.json | 3 + rush.json | 6 + 17 files changed, 2089 insertions(+) create mode 100644 plugins/browser-plugin-element-tracking/LICENSE create mode 100644 plugins/browser-plugin-element-tracking/README.md create mode 100644 plugins/browser-plugin-element-tracking/index.html create mode 100644 plugins/browser-plugin-element-tracking/jest.config.js create mode 100644 plugins/browser-plugin-element-tracking/package.json create mode 100644 plugins/browser-plugin-element-tracking/rollup.config.js create mode 100644 plugins/browser-plugin-element-tracking/src/api.ts create mode 100644 plugins/browser-plugin-element-tracking/src/configuration.ts create mode 100644 plugins/browser-plugin-element-tracking/src/data.ts create mode 100644 plugins/browser-plugin-element-tracking/src/index.ts create mode 100644 plugins/browser-plugin-element-tracking/src/schemata.ts create mode 100644 plugins/browser-plugin-element-tracking/src/types.ts create mode 100644 plugins/browser-plugin-element-tracking/test/api.test.ts create mode 100644 plugins/browser-plugin-element-tracking/test/pingInterval.test.ts create mode 100644 plugins/browser-plugin-element-tracking/test/sessionStats.test.ts create mode 100644 plugins/browser-plugin-element-tracking/tsconfig.json diff --git a/plugins/browser-plugin-element-tracking/LICENSE b/plugins/browser-plugin-element-tracking/LICENSE new file mode 100644 index 000000000..a311732d5 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2023 Snowplow Analytics Ltd, 2010 Anthon Pang +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/plugins/browser-plugin-element-tracking/README.md b/plugins/browser-plugin-element-tracking/README.md new file mode 100644 index 000000000..770b47c8b --- /dev/null +++ b/plugins/browser-plugin-element-tracking/README.md @@ -0,0 +1,59 @@ +# Snowplow Element Tracking Plugin + +[![npm version][npm-image]][npm-url] +[![License][license-image]](LICENSE) + +Browser Plugin to be used with `@snowplow/browser-tracker`. + +This plugin is allows tracking the addition/removal and visibility of page elements. + +## Maintainer quick start + +Part of the Snowplow JavaScript Tracker monorepo. +Build with [Node.js](https://nodejs.org/en/) (18 - 20) and [Rush](https://rushjs.io/). + +### Setup repository + +```bash +npm install -g @microsoft/rush +git clone https://github.com/snowplow/snowplow-javascript-tracker.git +rush update +``` + +## Package Installation + +With npm: + +```bash +npm install @snowplow/browser-plugin-element-tracking +``` + +## Usage + +Initialize your tracker with the SnowplowMediaPlugin: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { SnowplowElementTrackingPlugin } from '@snowplow/browser-plugin-element-tracking'; + +newTracker('sp1', '{{collector_url}}', { + appId: 'my-app-id', + plugins: [ SnowplowElementTrackingPlugin() ], +}); +``` + +For a full API reference, you can read the plugin [documentation page](https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/browser-tracker/browser-tracker-v3-reference/plugins/element-tracking/). + +## Copyright and license + +Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). + +Copyright (c) 2024 Snowplow Analytics Ltd. + +All rights reserved. + +[npm-url]: https://www.npmjs.com/package/@snowplow/browser-plugin-element-tracking +[npm-image]: https://img.shields.io/npm/v/@snowplow/browser-plugin-element-tracking +[docs]: https://docs.snowplowanalytics.com/docs/collecting-data/collecting-from-own-applications/javascript-tracker/ +[osi]: https://opensource.org/licenses/BSD-3-Clause +[license-image]: https://img.shields.io/npm/l/@snowplow/browser-plugin-element-tracking diff --git a/plugins/browser-plugin-element-tracking/index.html b/plugins/browser-plugin-element-tracking/index.html new file mode 100644 index 000000000..40629645b --- /dev/null +++ b/plugins/browser-plugin-element-tracking/index.html @@ -0,0 +1,110 @@ + + + + Test Page For Element Tracking Plugin + + + + +
Some text content
+ A link + +
+ +
+ +
+ +
+ + + + + + diff --git a/plugins/browser-plugin-element-tracking/jest.config.js b/plugins/browser-plugin-element-tracking/jest.config.js new file mode 100644 index 000000000..bd3ea4e2a --- /dev/null +++ b/plugins/browser-plugin-element-tracking/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-element-tracking/package.json b/plugins/browser-plugin-element-tracking/package.json new file mode 100644 index 000000000..1638a3372 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/package.json @@ -0,0 +1,53 @@ +{ + "name": "@snowplow/browser-plugin-element-tracking", + "version": "3.24.4", + "description": "Snowplow element tracking", + "homepage": "https://github.com/snowplow/snowplow-javascript-tracker", + "bugs": "https://github.com/snowplow/snowplow-javascript-tracker/issues", + "repository": { + "type": "git", + "url": "https://github.com/snowplow/snowplow-javascript-tracker.git" + }, + "license": "BSD-3-Clause", + "author": "Snowplow Analytics Ltd (https://snowplow.io/)", + "sideEffects": false, + "main": "./dist/index.umd.js", + "module": "./dist/index.module.js", + "types": "./dist/index.module.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c --silent --failAfterWarnings", + "test": "jest" + }, + "dependencies": { + "@snowplow/browser-tracker-core": "workspace:*", + "@snowplow/tracker-core": "workspace:*", + "tslib": "^2.3.1" + }, + "devDependencies": { + "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", + "@rollup/plugin-commonjs": "~21.0.2", + "@rollup/plugin-node-resolve": "~13.1.3", + "@types/jest": "~27.4.1", + "@types/jsdom": "~16.2.14", + "@typescript-eslint/eslint-plugin": "~5.15.0", + "@typescript-eslint/parser": "~5.15.0", + "eslint": "~8.11.0", + "jest": "~27.5.1", + "jest-environment-jsdom": "~27.5.1", + "jest-environment-jsdom-global": "~3.0.0", + "jest-standard-reporter": "~2.0.0", + "rollup": "~2.70.1", + "rollup-plugin-cleanup": "~3.2.1", + "rollup-plugin-license": "~2.6.1", + "rollup-plugin-terser": "~7.0.2", + "rollup-plugin-ts": "~2.0.5", + "ts-jest": "~27.1.3", + "typescript": "~4.6.2" + }, + "peerDependencies": { + "@snowplow/browser-tracker": "~3.24.4" + } +} diff --git a/plugins/browser-plugin-element-tracking/rollup.config.js b/plugins/browser-plugin-element-tracking/rollup.config.js new file mode 100644 index 000000000..3a764e152 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/rollup.config.js @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE + * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL + * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR + * SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER + * CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, + * OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +import { nodeResolve } from '@rollup/plugin-node-resolve'; +import commonjs from '@rollup/plugin-commonjs'; +import ts from 'rollup-plugin-ts'; // Prefered over @rollup/plugin-typescript as it bundles .d.ts files +import { banner } from '../../banner'; +import compiler from '@ampproject/rollup-plugin-closure-compiler'; +import { terser } from 'rollup-plugin-terser'; +import cleanup from 'rollup-plugin-cleanup'; +import pkg from './package.json'; +import { builtinModules } from 'module'; + +const umdPlugins = [nodeResolve({ browser: true }), commonjs(), ts()]; +const umdName = 'snowplowElementTracking'; + +export default [ + // CommonJS (for Node) and ES module (for bundlers) build. + { + input: './src/index.ts', + plugins: [...umdPlugins, banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main, format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + plugins: [...umdPlugins, compiler(), terser(), cleanup({ comments: 'none' }), banner()], + treeshake: { moduleSideEffects: ['sha1'] }, + output: [{ file: pkg.main.replace('.js', '.min.js'), format: 'umd', sourcemap: true, name: umdName }], + }, + { + input: './src/index.ts', + external: [...builtinModules, ...Object.keys(pkg.dependencies), ...Object.keys(pkg.devDependencies)], + plugins: [ + ts(), // so Rollup can convert TypeScript to JavaScript + banner(), + ], + output: [{ file: pkg.module, format: 'es', sourcemap: true }], + }, +]; diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts new file mode 100644 index 000000000..b49728863 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -0,0 +1,323 @@ +import { + type BrowserPlugin, + type BrowserTracker, + dispatchToTrackersInCollection, +} from '@snowplow/browser-tracker-core'; +import { type Logger, buildSelfDescribingEvent } from '@snowplow/tracker-core'; + +import { checkConfig, type Configuration, ConfigurationState, type ElementConfiguration } from './configuration'; +import { extractSelectorDetails } from './data'; +import { ComponentsEntity, ElementContentEntity, Entities, Entity, Events, Event } from './schemata'; +import type { OneOrMany } from './types'; + +type ElementTrackingConfiguration = { + elements: OneOrMany; +}; + +type ElementTrackingDisable = + | { + elements: OneOrMany; + } + | { elementIds: OneOrMany> }; + +const trackers: Record = {}; +const configurations: Configuration[] = []; +const configurationsById: Record = {}; + +let LOG: Logger | undefined = undefined; +let mutationObserver: MutationObserver | false = false; +let intersectionObserver: IntersectionObserver | false = false; + +/** + * Element Tracking plugin to track the (dis)appearance and visiblity of DOM elements. + */ +export function SnowplowElementTrackingPlugin(): BrowserPlugin { + return { + activateBrowserPlugin(tracker) { + trackers[tracker.id] = tracker; + + if (!mutationObserver) { + mutationObserver = typeof MutationObserver === 'function' && new MutationObserver(mutationCallback); + if (mutationObserver) + mutationObserver.observe(document.documentElement, { + attributes: true, + childList: true, + subtree: true, + }); + } + intersectionObserver = + intersectionObserver || + (typeof IntersectionObserver === 'function' && new IntersectionObserver(intersectionCallback)); + }, + logger(logger) { + LOG = logger; + }, + }; +} + +/** + * Starts element tracking for a single media content tracked in a media player. + * The tracking instance is uniquely identified by a given ID. + * All subsequent media track calls will be processed within this media tracking if given the same ID. + * + * @param config Configuration for setting up media tracking + * @param trackers The tracker identifiers which ping events will be sent to + */ +export function startElementTracking({ elements = [] }: ElementTrackingConfiguration, trackers?: Array): void { + if (!mutationObserver || !intersectionObserver) { + LOG?.error('ElementTracking plugin requires both MutationObserver and IntersectionObserver APIs'); + if (mutationObserver) mutationObserver.disconnect(); + if (intersectionObserver) intersectionObserver.disconnect(); + mutationObserver = intersectionObserver = false; + return; + } + + const configs = Array.isArray(elements) ? elements : [elements]; + + configs.forEach((config) => { + try { + const valid = checkConfig(config, trackers); + configurations.push(valid); + if (valid.id) configurationsById[valid.id] = valid; + } catch (e) { + LOG?.error('Failed to process Element Tracking configuration', e, config); + } + }); + + configurations.forEach((config) => { + const { create, expose, obscure, selector, state } = config; + if (state === ConfigurationState.INITIAL) { + config.state = ConfigurationState.CONFIGURED; + + const elements = Array.from(document.querySelectorAll(selector)); + + if (create && elements.length) { + // elements exist, that's a create + elements.forEach((element, i) => + trackEvent(Events.ELEMENT_CREATE, config, { element, position: i + 1, matches: elements.length }) + ); + config.state = ConfigurationState.CREATED; + } + + if (intersectionObserver && (expose || obscure)) { + elements.forEach((e) => intersectionObserver && intersectionObserver.observe(e)); + } + } + }); +} + +export function endElementTracking(remove?: ElementTrackingDisable, trackers?: Array): void { + if (!remove) { + configurations.length = 0; + } + + if (remove && 'elementIds' in remove) { + const { elementIds } = remove; + const idsToRemove = Array.isArray(elementIds) ? elementIds : [elementIds]; + + const remaining = configurations.filter((config) => { + const { id } = config; + + const targeted = typeof id === undefined || !idsToRemove.includes(id!); + // TODO(jethron): remove for specific trackers + return targeted && !trackers; + }); + configurations.splice(0, configurations.length, ...remaining); + } + + if (remove && 'elements' in remove) { + const { elements } = remove; + const elsToRemove = Array.isArray(elements) ? elements : [elements]; + + const remaining = configurations.filter((config) => { + const { name } = config; + const targeted = typeof name === undefined || !elsToRemove.includes(name); + // TODO(jethron): remove for specific trackers + return targeted && !trackers; + }); + configurations.splice(0, configurations.length, ...remaining); + } + + if (!configurations.length) { + if (intersectionObserver) intersectionObserver.disconnect(); + if (mutationObserver) mutationObserver.disconnect(); + mutationObserver = intersectionObserver = false; + } +} + +const componentGenerator = (...params: any[]): ComponentsEntity | null => { + const elementParams = params.filter((arg) => arg instanceof Node && nodeIsElement(arg)); + + if (!elementParams.length) return null; + + const components: string[] = []; + + elementParams.forEach((elem) => { + configurations.forEach((config) => { + if (!config.component) return; + + const ancestor = elem.closest(config.selector); + if (ancestor !== null) components.push(config.name); + }); + }); + + return components.length + ? { + schema: Entities.COMPONENT_PARENTS, + data: { + component_list: components, + }, + } + : null; +}; + +export function getComponentListGenerator(fn?: (generator: typeof componentGenerator) => void) { + if (fn) fn(componentGenerator); + return componentGenerator; +} + +function trackEvent( + schema: T, + config: Configuration, + options?: Partial<{ + element: Element | HTMLElement; + boundingRect: DOMRect; + position: number; + matches: number; + }> +): void { + const { element, boundingRect, position, matches } = options ?? {}; + + dispatchToTrackersInCollection(config.trackers, trackers, (tracker) => { + const payload: Event = { + schema, + data: { + element_name: config.name, + }, + }; + const event = buildSelfDescribingEvent({ event: payload }); + const context: Entity[] = []; + + if (element) { + if (config.details) { + const rect = boundingRect ?? element.getBoundingClientRect(); + context.push({ + schema: Entities.ELEMENT_DETAILS, + data: { + element_name: config.name, + width: rect.width, + height: rect.height, + position_x: rect.x, + position_y: rect.y, + position, + matches, + attributes: extractSelectorDetails(element, config.selector, config.details), + }, + }); + } + + if (config.contents.length) { + context.push(...buildContentTree(config, element, position)); + } + + const components = componentGenerator(element); + if (components) context.push(components); + } + + if (config.aggregateStats) { + } + + tracker.core.track(event, context); + }); +} + +function mutationCallback(mutations: MutationRecord[]): void { + mutations.forEach((record) => { + if (record.type === 'attributes') { + // TODO: what if existing node now matches selector? + } else if (record.type === 'childList') { + configurations.forEach((config) => { + record.addedNodes.forEach((node) => { + if (nodeIsElement(node) && node.matches(config.selector)) { + if (config.create) trackEvent(Events.ELEMENT_CREATE, config, { element: node }); + + config.state = ConfigurationState.CREATED; + if (config.expose && intersectionObserver) intersectionObserver.observe(node); + } + }); + record.removedNodes.forEach((node) => { + if (nodeIsElement(node) && node.matches(config.selector)) { + if (config.obscure && config.state === ConfigurationState.EXPOSED) + trackEvent(Events.ELEMENT_OBSCURE, config, { element: node }); + if (config.destroy) trackEvent(Events.ELEMENT_DESTROY, config, { element: node }); + config.state = ConfigurationState.DESTROYED; + } + }); + }); + } + }); +} + +function intersectionCallback(entries: IntersectionObserverEntry[]): void { + entries.forEach((entry) => { + configurations.forEach((config) => { + if (entry.target.matches(config.selector)) { + const siblings = Array.from(document.querySelectorAll(config.selector)); + const position = siblings.findIndex((el) => el.isSameNode(entry.target)) + 1; + + if (entry.isIntersecting) { + if (config.expose) { + trackEvent(Events.ELEMENT_EXPOSE, config, { + element: entry.target, + boundingRect: entry.boundingClientRect, + position, + matches: siblings.length, + }); + } + config.state = ConfigurationState.EXPOSED; + } else if (config.state !== ConfigurationState.DESTROYED) { + if (config.obscure) { + trackEvent(Events.ELEMENT_OBSCURE, config, { + element: entry.target, + boundingRect: entry.boundingClientRect, + position, + matches: siblings.length, + }); + } + + config.state = ConfigurationState.OBSCURED; + } + } + }); + }); +} + +function buildContentTree(config: Configuration, element: Element, parentPosition: number = 1): ElementContentEntity[] { + const context: ElementContentEntity[] = []; + if (element && config.contents.length) { + config.contents.forEach((contentConfig) => { + const contents = Array.from(element.querySelectorAll(contentConfig.selector)); + + contents.forEach((contentElement, i) => { + context.push({ + schema: Entities.ELEMENT_CONTENT, + data: { + name: contentConfig.name, + parent_name: config.name, + parent_position: parentPosition, + position: i + 1, + attributes: extractSelectorDetails(contentElement, contentConfig.selector, contentConfig.details), + }, + }); + + context.push(...buildContentTree(contentConfig, contentElement, i + 1)); + }); + }); + } + + return context; +} + +function nodeIsElement(node: Node): node is Element { + return node.nodeType === Node.ELEMENT_NODE; +} diff --git a/plugins/browser-plugin-element-tracking/src/configuration.ts b/plugins/browser-plugin-element-tracking/src/configuration.ts new file mode 100644 index 000000000..c3170709f --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/configuration.ts @@ -0,0 +1,157 @@ +import { type DataSelector, isDataSelector } from './data'; +import type { OneOrMany, RequiredExcept } from './types'; + +export enum ConfigurationState { + INITIAL, + CONFIGURED, + CREATED, + DESTROYED, + EXPOSED, + OBSCURED, +} + +enum Frequency { + ALWAYS = 'always', + ELEMENT = 'element', + ONCE = 'once', + NEVER = 'never', + PAGEVIEW = 'pageview', +} + +type BaseOptions = { + when: `${Frequency}`; + condition?: DataSelector; +}; + +type ExposeOptions = BaseOptions & { + minPercentage?: number; + minTimeMillis?: number; + minSize?: number; + boundaryPixels?: [number, number, number, number] | [number, number] | number; +}; + +export type ElementConfiguration = { + name?: string; + selector: string; + create?: boolean | BaseOptions; + destroy?: boolean | BaseOptions; + expose?: boolean | ExposeOptions; + obscure?: boolean | BaseOptions; + aggregateStats?: boolean; + component?: boolean; + details?: OneOrMany; + contents?: OneOrMany; + id?: string; +}; + +export type Configuration = Omit< + RequiredExcept, + 'create' | 'destroy' | 'expose' | 'obscure' | 'details' | 'contents' +> & { + trackers?: string[]; + create: BaseOptions | null; + destroy: BaseOptions | null; + expose: RequiredExcept | null; + obscure: BaseOptions | null; + state: ConfigurationState; + details: DataSelector[]; + contents: Configuration[]; +}; + +const DEFAULT_FREQUENCY_OPTIONS: BaseOptions = { when: 'always' }; + +export function checkConfig(config: ElementConfiguration, trackers?: string[]): Configuration { + const { selector, name = selector, id, aggregateStats = false, component = false } = config; + + if (typeof name !== 'string' || !name) throw new Error(`Invalid element name value: ${name}`); + if (typeof selector !== 'string' || !selector) throw new Error(`Invalid element selector value: ${selector}`); + + document.querySelector(config.selector); // this will throw if selector invalid + + const { create = false, destroy = false, expose = true, obscure = false } = config; + + const [validCreate, validDestroy, validObscure] = [create, destroy, obscure].map((input) => { + if (!input) return null; + if (typeof input === 'object') { + const { when = 'never', condition } = input; + + if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition'); + + if (when.toUpperCase() in Frequency) { + return { + when: when as Frequency, + condition, + }; + } else { + throw new Error(`Unknown tracking frequency: ${when}`); + } + } + return DEFAULT_FREQUENCY_OPTIONS; + }); + + let validExpose: RequiredExcept | null = null; + + if (expose) { + if (typeof expose === 'object') { + const { + when = 'never', + condition, + boundaryPixels = 0, + minPercentage = 0, + minSize = 0, + minTimeMillis = 0, + } = expose; + + if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition'); + if ( + typeof boundaryPixels !== 'number' || + typeof minPercentage !== 'number' || + typeof minSize !== 'number' || + typeof minTimeMillis !== 'number' + ) + throw new Error('Invalid expose options provided'); + + if (when.toUpperCase() in Frequency) { + validExpose = { + when: when as Frequency, + condition, + boundaryPixels, + minPercentage, + minSize, + minTimeMillis, + }; + } else { + throw new Error(`Unknown tracking frequency: ${when}`); + } + } else { + validExpose = { + ...DEFAULT_FREQUENCY_OPTIONS, + boundaryPixels: 0, + minPercentage: 0, + minSize: 0, + minTimeMillis: 0, + }; + } + } + + let { details = [], contents = [] } = config; + + if (!Array.isArray(details)) details = details == null ? [] : [details]; + if (!Array.isArray(contents)) contents = contents == null ? [] : [contents]; + + return { + name, + selector, + id, + create: validCreate, + destroy: validDestroy, + expose: validExpose, + obscure: validObscure, + aggregateStats: !!aggregateStats, + component: !!component, + details, + contents: contents.map((inner) => checkConfig(inner, trackers)), + trackers, + state: ConfigurationState.INITIAL, + }; +} diff --git a/plugins/browser-plugin-element-tracking/src/data.ts b/plugins/browser-plugin-element-tracking/src/data.ts new file mode 100644 index 000000000..4cc5c126a --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -0,0 +1,135 @@ +import { AttributeList } from './types'; + +export type DataSelector = + | ((element: Element) => Record) + | { attributes: string[] } + | { properties: string[] } + | { dataset: string[] } + | { selector: boolean } + | { content: Record } + | { match: Record }; + +/** + * Type guard to determine if `val` is a valid `DataSelector` function or descriptor + */ +export function isDataSelector(val: unknown): val is DataSelector { + if (val == null) return false; + if (typeof val === 'function') return true; + + type KeysOf = T extends Function ? never : keyof T; + type AllOf = Exclude extends never + ? Exclude extends never + ? T + : Exclude + : Exclude; + + const knownKeys = ['match', 'content', 'selector', 'dataset', 'attributes', 'properties'] as const; + const selectorKeys: AllOf, typeof knownKeys> = knownKeys; // type error if we add a new selector without updating knownKeys + + if (typeof val === 'object') return selectorKeys.some((key) => key in val); + return false; +} + +export function extractSelectorDetails(element: Element, path: string, selectors: DataSelector[]): AttributeList { + return selectors.reduce((attributes: AttributeList, selector) => { + const result = evaluateDataSelector(element, path, selector); + + if (result.length) { + return attributes.concat(result); + } + + return attributes; + }, []); +} + +function evaluateDataSelector(element: HTMLElement | Element, path: string, selector: DataSelector): AttributeList { + const result: AttributeList = []; + + type DataSelectorType = (T extends T ? keyof T : never) | 'callback'; + let source: DataSelectorType = 'callback'; + + if (typeof selector === 'function') { + try { + const discovered = selector(element); + for (const attribute in discovered) { + if (typeof attribute === 'string' && discovered.hasOwnProperty(attribute)) { + const value = + typeof discovered[attribute] === 'object' + ? JSON.stringify(discovered[attribute]) + : String(discovered[attribute]); + result.push({ source, attribute, value }); + } + } + } catch (e) { + const value = e instanceof Error ? e.message || e.name : String(e); + result.push({ source: 'error', attribute: 'message', value }); + } + + return result; + } + + source = 'attributes'; + if (source in selector && Array.isArray(selector[source])) { + selector[source].forEach((attribute) => { + const value = element.getAttribute(attribute); + + if (value !== null) { + result.push({ source, attribute, value }); + } + }); + } + + source = 'properties'; + if (source in selector && Array.isArray(selector[source])) { + selector[source].forEach((attribute) => { + const value = (element as any)[attribute]; + + if (typeof value !== 'object' && typeof value !== 'undefined') { + result.push({ source, attribute, value: String(value) }); + } + }); + } + + source = 'dataset'; + if (source in selector && Array.isArray(selector[source])) { + selector[source].forEach((attribute) => { + if ('dataset' in element) { + const value = element.dataset[attribute]; + + if (typeof value !== 'undefined') { + result.push({ source, attribute, value: String(value) }); + } + } + }); + } + + source = 'content'; + if (source in selector && typeof selector[source] === 'object' && selector[source]) { + Object.entries(selector[source]).forEach(([attribute, pattern]) => { + if (!(pattern instanceof RegExp)) + try { + pattern = new RegExp(pattern); + } catch (e) { + return; + } + + if (element.textContent) { + try { + const match = pattern.exec(element.textContent); + if (match) { + result.push({ source, attribute, value: match.length > 1 ? match[1] : match[0] }); + } + } catch (e) { + console.error(e); + } + } + }); + } + + source = 'selector'; + if (source in selector && selector[source]) { + result.push({ source, attribute: source, value: path }); + } + + return result; +} diff --git a/plugins/browser-plugin-element-tracking/src/index.ts b/plugins/browser-plugin-element-tracking/src/index.ts new file mode 100644 index 000000000..b1c13e734 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/index.ts @@ -0,0 +1 @@ +export * from './api'; diff --git a/plugins/browser-plugin-element-tracking/src/schemata.ts b/plugins/browser-plugin-element-tracking/src/schemata.ts new file mode 100644 index 000000000..56439303c --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/schemata.ts @@ -0,0 +1,91 @@ +import { SelfDescribingJson } from '@snowplow/tracker-core'; + +import type { AttributeList } from './types'; + +export enum Events { + ELEMENT_CREATE = 'iglu:com.snowplowanalytics.snowplow/create_element/jsonschema/1-0-0', + ELEMENT_DESTROY = 'iglu:com.snowplowanalytics.snowplow/destroy_element/jsonschema/1-0-0', + ELEMENT_EXPOSE = 'iglu:com.snowplowanalytics.snowplow/expose_element/jsonschema/1-0-0', + ELEMENT_OBSCURE = 'iglu:com.snowplowanalytics.snowplow/obscure_element/jsonschema/1-0-0', +} + +export enum Entities { + ELEMENT_DETAILS = 'iglu:com.snowplowanalytics.snowplow/element/jsonschema/1-0-0', + ELEMENT_CONTENT = 'iglu:com.snowplowanalytics.snowplow/element_content/jsonschema/1-0-0', + ELEMENT_STATISTICS = 'iglu:com.snowplowanalytics.snowplow/element_statistics/jsonschema/1-0-0', + COMPONENT_PARENTS = 'iglu:com.snowplowanalytics.snowplow/component_parents/jsonschema/1-0-0', +} + +export type SDJ> = SelfDescribingJson & { + schema: S; +}; + +export type Event> = SDJ; +export type Entity> = SDJ; + +export type ElementCreateEvent = SDJ< + Events.ELEMENT_CREATE, + { + element_name: string; + } +>; + +export type ElementDestroyEvent = SDJ< + Events.ELEMENT_DESTROY, + { + element_name: string; + } +>; + +export type ElementExposeEvent = SDJ< + Events.ELEMENT_EXPOSE, + { + element_name: string; + } +>; + +export type ElementObscureEvent = SDJ< + Events.ELEMENT_OBSCURE, + { + element_name: string; + } +>; + +export type ElementContentEntity = SDJ< + Entities.ELEMENT_CONTENT, + { + parent_name: string; + parent_position: number; + name: string; + position: number; + attributes?: AttributeList; + } +>; + +export type ElementDetailsEntity = SDJ< + Entities.ELEMENT_DETAILS, + { + element_name: string; + height: number; + width: number; + position_x: number; + position_y: number; + position?: number; + matches?: number; + attributes: AttributeList; + } +>; + +export type ComponentsEntity = SDJ< + Entities.COMPONENT_PARENTS, + { + component_list: string[]; + } +>; + +export type ElementStatisticsEntity = SDJ< + Entities.ELEMENT_STATISTICS, + { + element_name: string; + } +>; diff --git a/plugins/browser-plugin-element-tracking/src/types.ts b/plugins/browser-plugin-element-tracking/src/types.ts new file mode 100644 index 000000000..52a99c8d2 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/types.ts @@ -0,0 +1,13 @@ +export type RequiredExcept = { + [P in Exclude]-?: Exclude; +} & { + [P in E]?: T[P]; +}; + +export type OneOrMany = T | T[]; + +export type AttributeList = { + source: string; + attribute: string; + value: string; +}[]; diff --git a/plugins/browser-plugin-element-tracking/test/api.test.ts b/plugins/browser-plugin-element-tracking/test/api.test.ts new file mode 100644 index 000000000..68803bb12 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/api.test.ts @@ -0,0 +1,749 @@ +import { addTracker, SharedState } from '@snowplow/browser-tracker-core'; +import { PayloadBuilder, SelfDescribingJson } from '@snowplow/tracker-core'; +import { + endMediaTracking, + SnowplowMediaPlugin, + startMediaTracking, + trackMediaAdBreakEnd, + trackMediaAdBreakStart, + trackMediaAdClick, + trackMediaAdComplete, + trackMediaAdFirstQuartile, + trackMediaAdMidpoint, + trackMediaAdPause, + trackMediaAdResume, + trackMediaAdSkip, + trackMediaAdStart, + trackMediaAdThirdQuartile, + trackMediaBufferEnd, + trackMediaBufferStart, + trackMediaEnd, + trackMediaError, + trackMediaFullscreenChange, + trackMediaPause, + trackMediaPictureInPictureChange, + trackMediaPlay, + trackMediaPlaybackRateChange, + trackMediaQualityChange, + trackMediaReady, + trackMediaSeekEnd, + trackMediaSeekStart, + trackMediaSelfDescribingEvent, + trackMediaVolumeChange, + updateMediaTracking, +} from '../src'; +import { getMediaEventSchema, MEDIA_PLAYER_SCHEMA, MEDIA_SESSION_SCHEMA } from '../src/schemata'; +import { MediaEventType } from '../src/types'; + +describe('Media Tracking API', () => { + let idx = 1; + let id = ''; + let eventQueue: { event: SelfDescribingJson; context: SelfDescribingJson[] }[] = []; + + beforeEach(() => { + addTracker(`sp${idx++}`, `sp${idx++}`, 'js-3.9.0', '', new SharedState(), { + stateStorageStrategy: 'cookie', + encodeBase64: false, + plugins: [ + SnowplowMediaPlugin(), + { + beforeTrack: (pb: PayloadBuilder) => { + const { ue_pr, co, tna } = pb.getPayload(); + if (tna == `sp${idx - 1}`) { + eventQueue.push({ event: JSON.parse(ue_pr as string).data, context: JSON.parse(co as string).data }); + } + }, + }, + ], + contexts: { webPage: false }, + }); + id = `media-${idx}`; + }); + + afterEach(() => { + endMediaTracking({ id }); + eventQueue = []; + }); + + describe('media player events', () => { + [ + { api: trackMediaReady, eventType: MediaEventType.Ready }, + { api: trackMediaPlay, eventType: MediaEventType.Play }, + { api: trackMediaPause, eventType: MediaEventType.Pause }, + { api: trackMediaEnd, eventType: MediaEventType.End }, + { api: trackMediaSeekStart, eventType: MediaEventType.SeekStart }, + { api: trackMediaSeekEnd, eventType: MediaEventType.SeekEnd }, + { api: trackMediaAdBreakStart, eventType: MediaEventType.AdBreakStart }, + { api: trackMediaAdBreakEnd, eventType: MediaEventType.AdBreakEnd }, + { api: trackMediaAdStart, eventType: MediaEventType.AdStart }, + { api: trackMediaAdComplete, eventType: MediaEventType.AdComplete }, + { api: trackMediaBufferStart, eventType: MediaEventType.BufferStart }, + { api: trackMediaBufferEnd, eventType: MediaEventType.BufferEnd }, + ].forEach((test) => { + it(`tracks a ${test.eventType} event`, () => { + startMediaTracking({ id, filterOutRepeatedEvents: false }); + + test.api({ id }); + + const { event } = eventQueue[0]; + + expect(event).toMatchObject({ + schema: getMediaEventSchema(test.eventType), + }); + }); + }); + + it('tracks a playback rate change event and remembers the new rate', () => { + startMediaTracking({ id, session: false, player: { playbackRate: 0.5 } }); + + trackMediaPlaybackRateChange({ id, newRate: 1.5 }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.PlaybackRateChange), + data: { + previousRate: 0.5, + newRate: 1.5, + }, + }, + context: [{ data: { playbackRate: 1.5 } }], + }, + { + context: [{ data: { playbackRate: 1.5 } }], + }, + ]); + }); + + it('tracks a volume change event and remembers the new volume', () => { + startMediaTracking({ id, session: false, player: { volume: 50 } }); + + trackMediaVolumeChange({ id, newVolume: 70 }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.VolumeChange), + data: { + previousVolume: 50, + newVolume: 70, + }, + }, + context: [{ data: { volume: 70 } }], + }, + { + context: [{ data: { volume: 70 } }], + }, + ]); + }); + + it('tracks a fullscreen change event and remembers the setting', () => { + startMediaTracking({ id, session: false }); + + trackMediaFullscreenChange({ id, fullscreen: true }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.FullscreenChange), + data: { fullscreen: true }, + }, + context: [{ data: { fullscreen: true } }], + }, + { + context: [{ data: { fullscreen: true } }], + }, + ]); + }); + + it('tracks a picture in picture change event and remembers the setting', () => { + startMediaTracking({ id, session: false }); + + trackMediaPictureInPictureChange({ id, pictureInPicture: true }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.PictureInPictureChange), + data: { pictureInPicture: true }, + }, + context: [{ data: { pictureInPicture: true } }], + }, + { + context: [{ data: { pictureInPicture: true } }], + }, + ]); + }); + + it('tracks an ad first quartile event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdFirstQuartile({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdFirstQuartile), + data: { percentProgress: 25 }, + }, + }, + ]); + }); + + it('tracks an ad midpoint event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdMidpoint({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdMidpoint), + data: { percentProgress: 50 }, + }, + }, + ]); + }); + + it('tracks an ad third quartile event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdThirdQuartile({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdThirdQuartile), + data: { percentProgress: 75 }, + }, + }, + ]); + }); + + it('tracks an ad skip event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdSkip({ id, percentProgress: 33.33 }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdSkip), + data: { percentProgress: 33 }, + }, + }, + ]); + }); + + it('tracks an ad click event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdClick({ id, percentProgress: 33.33 }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdClick), + data: { percentProgress: 33 }, + }, + }, + ]); + }); + + it('tracks an ad pause event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdPause({ id, percentProgress: 33.33 }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdPause), + data: { percentProgress: 33 }, + }, + }, + ]); + }); + + it('tracks an ad resume event', () => { + startMediaTracking({ id, session: false }); + + trackMediaAdResume({ id, percentProgress: 33.33 }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.AdResume), + data: { percentProgress: 33 }, + }, + }, + ]); + }); + + it('tracks quality change event and remembers the setting', () => { + startMediaTracking({ id, session: false, player: { quality: '720p' } }); + + trackMediaQualityChange({ + id, + newQuality: '1080p', + bitrate: 1000, + framesPerSecond: 30, + automatic: false, + }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.QualityChange), + data: { + previousQuality: '720p', + newQuality: '1080p', + bitrate: 1000, + framesPerSecond: 30, + automatic: false, + }, + }, + context: [{ data: { quality: '1080p' } }], + }, + { context: [{ data: { quality: '1080p' } }] }, + ]); + }); + + it('tracks error event', () => { + startMediaTracking({ id, session: false }); + + trackMediaError({ + id, + errorCode: '500', + errorName: 'forbidden', + errorDescription: 'Failed to load media', + }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.Error), + data: { + errorCode: '500', + errorName: 'forbidden', + errorDescription: 'Failed to load media', + }, + }, + }, + ]); + }); + + it('sets paused to false in media context when play is tracked', () => { + startMediaTracking({ id, player: { paused: true }, session: false }); + trackMediaPlay({ id }); + + expect(eventQueue).toMatchObject([ + { + context: [{ data: { paused: false } }], + }, + ]); + }); + + it('sets paused to true in media context when pause is tracked', () => { + startMediaTracking({ id, player: { paused: false }, session: false }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + context: [{ data: { paused: true } }], + }, + ]); + }); + + it('sets paused and ended to true in media context when end is tracked', () => { + startMediaTracking({ id, player: { paused: false }, session: false }); + trackMediaEnd({ id }); + + expect(eventQueue).toMatchObject([ + { + context: [{ data: { paused: true, ended: true } }], + }, + ]); + }); + + describe('filtering repeated events', () => { + it('doesnt track seek start and end multiple times', () => { + startMediaTracking({ id, player: { duration: 100 }, session: false }); + trackMediaSeekStart({ id, player: { currentTime: 1 } }); + trackMediaSeekEnd({ id, player: { currentTime: 2 } }); + trackMediaSeekStart({ id, player: { currentTime: 2 } }); + trackMediaSeekEnd({ id, player: { currentTime: 3 } }); + trackMediaSeekStart({ id, player: { currentTime: 3 } }); + trackMediaSeekEnd({ id, player: { currentTime: 4 } }); + trackMediaPlay({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { schema: getMediaEventSchema(MediaEventType.SeekStart) }, + context: [{ data: { currentTime: 1 } }], + }, + { + event: { schema: getMediaEventSchema(MediaEventType.SeekEnd) }, + context: [{ data: { currentTime: 4 } }], + }, + { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, + ]); + }); + + it('doesnt filter out repeated seek events when disabled', () => { + startMediaTracking({ id, filterOutRepeatedEvents: { seekEvents: false } }); + trackMediaSeekStart({ id, player: { currentTime: 1 } }); + trackMediaSeekEnd({ id, player: { currentTime: 2 } }); + trackMediaSeekStart({ id, player: { currentTime: 2 } }); + trackMediaSeekEnd({ id, player: { currentTime: 3 } }); + trackMediaSeekStart({ id, player: { currentTime: 3 } }); + trackMediaSeekEnd({ id, player: { currentTime: 4 } }); + + expect(eventQueue.length).toBe(6); + }); + + it('doesnt track volume change multiple times', () => { + startMediaTracking({ id, session: false }); + trackMediaVolumeChange({ id, newVolume: 50 }); + trackMediaVolumeChange({ id, newVolume: 60 }); + trackMediaVolumeChange({ id, newVolume: 70 }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.VolumeChange), + data: { + previousVolume: 60, + newVolume: 70, + }, + }, + }, + { event: { schema: getMediaEventSchema(MediaEventType.Pause) } }, + ]); + }); + + it('doesnt filter out repeated volume change events when disabled', () => { + startMediaTracking({ id, filterOutRepeatedEvents: { volumeChangeEvents: false } }); + trackMediaVolumeChange({ id, newVolume: 50 }); + trackMediaVolumeChange({ id, newVolume: 60 }); + trackMediaVolumeChange({ id, newVolume: 70 }); + trackMediaPause({ id }); + + expect(eventQueue.length).toBe(4); + }); + + it('flushes aggregated events on end tracking', () => { + startMediaTracking({ id, session: false }); + trackMediaVolumeChange({ id, newVolume: 50 }); + trackMediaVolumeChange({ id, newVolume: 60 }); + trackMediaVolumeChange({ id, newVolume: 70 }); + endMediaTracking({ id }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: getMediaEventSchema(MediaEventType.VolumeChange), + data: { + previousVolume: 60, + newVolume: 70, + }, + }, + }, + ]); + }); + + it('remembers context entities on flush', () => { + startMediaTracking({ id, session: false }); + trackMediaVolumeChange({ id, newVolume: 50 }); + trackMediaVolumeChange({ id, newVolume: 60 }); + trackMediaVolumeChange({ id, newVolume: 70, context: [{ schema: 'entity', data: {} }] }); + endMediaTracking({ id }); + + expect(eventQueue).toMatchObject([{ context: [{ schema: MEDIA_PLAYER_SCHEMA }, { schema: 'entity' }] }]); + }); + + it('flushes events that are waiting to be filtered automatically after timeout', (done) => { + startMediaTracking({ id, filterOutRepeatedEvents: { flushTimeoutMs: 0 } }); + trackMediaVolumeChange({ id, newVolume: 50 }); + + setTimeout(() => { + expect(eventQueue.length).toBe(1); + done(); + }, 0); + }); + }); + + it('adds custom context entities to all events', () => { + const context: Array = [{ schema: 'test', data: {} }]; + startMediaTracking({ id, context, session: false }); + + trackMediaPlay({ id }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([ + { context: [{ data: { paused: false } }, { schema: 'test' }] }, + { context: [{ data: { paused: true } }, { schema: 'test' }] }, + ]); + }); + + it('doesnt track events not in captureEvents', () => { + startMediaTracking({ id, captureEvents: [MediaEventType.Pause], session: false }); + + trackMediaPlay({ id }); + trackMediaPause({ id }); + + expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Pause) } }]); + }); + + it('tracks a custom self-describing event', () => { + startMediaTracking({ id }); + + trackMediaSelfDescribingEvent({ + id, + event: { + schema: 'iglu:com.acme/event/jsonschema/1-0-0', + data: { foo: 'bar' }, + }, + }); + + expect(eventQueue).toMatchObject([ + { + event: { + schema: 'iglu:com.acme/event/jsonschema/1-0-0', + data: { foo: 'bar' }, + }, + context: [{ schema: MEDIA_PLAYER_SCHEMA }, { schema: MEDIA_SESSION_SCHEMA }], + }, + ]); + }); + }); + + describe('session', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.clearAllTimers(); + }); + + it('adds media session context entity with given ID', () => { + startMediaTracking({ id }); + trackMediaReady({ id }); + + const { context, event } = eventQueue[0]; + + expect(context).toMatchObject([ + { + schema: MEDIA_PLAYER_SCHEMA, + }, + { + data: { mediaSessionId: id }, + schema: MEDIA_SESSION_SCHEMA, + }, + ]); + + expect(event).toMatchObject({ + schema: getMediaEventSchema(MediaEventType.Ready), + }); + }); + + it('adds media session context entity with given started at date', () => { + let startedAt = new Date(new Date().getTime() - 100 * 1000); + startMediaTracking({ id, session: { startedAt: startedAt } }); + trackMediaReady({ id }); + + const { context, event } = eventQueue[0]; + + expect(context).toMatchObject([ + { + schema: MEDIA_PLAYER_SCHEMA, + }, + { + data: { startedAt: startedAt.toISOString() }, + schema: MEDIA_SESSION_SCHEMA, + }, + ]); + + expect(event).toMatchObject({ + schema: getMediaEventSchema(MediaEventType.Ready), + }); + }); + + it('calculates session stats', () => { + startMediaTracking({ id, player: { duration: 10 } }); + trackMediaPlay({ id }); + jest.advanceTimersByTime(10 * 1000); + updateMediaTracking({ id, player: { currentTime: 10 } }); + trackMediaEnd({ id, player: { currentTime: 10 } }); + + expect(eventQueue).toMatchObject([ + { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, + { + event: { schema: getMediaEventSchema(MediaEventType.End) }, + context: [ + { schema: MEDIA_PLAYER_SCHEMA }, + { + schema: MEDIA_SESSION_SCHEMA, + data: { + timePlayed: 10, + contentWatched: 11, + }, + }, + ], + }, + ]); + }); + }); + + describe('ping events', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.clearAllTimers(); + }); + + it('starts sending ping events after session starts', () => { + startMediaTracking({ id, pings: true }); + + jest.advanceTimersByTime(30 * 1000); + + expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Ping) } }]); + }); + + it('should make a ping event in a custom interval', () => { + startMediaTracking({ id, pings: { pingInterval: 1 } }); + + jest.advanceTimersByTime(1000); + + expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Ping) } }]); + }); + + it('should send ping events regardless of other events', () => { + startMediaTracking({ id, pings: { pingInterval: 1, maxPausedPings: 10 } }); + trackMediaPlay({ id }); + jest.advanceTimersByTime(1000); + trackMediaPause({ id }); + jest.advanceTimersByTime(2000); + + expect(eventQueue).toMatchObject([ + { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, + { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, + { event: { schema: getMediaEventSchema(MediaEventType.Pause) } }, + { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, + { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, + ]); + }); + + it('should not send more ping events than max when paused', () => { + startMediaTracking({ id, pings: { pingInterval: 1, maxPausedPings: 1 } }); + trackMediaPause({ id }); + jest.advanceTimersByTime(1000); + jest.advanceTimersByTime(2000); + jest.advanceTimersByTime(3000); + + expect(eventQueue).toMatchObject([ + { event: { schema: getMediaEventSchema(MediaEventType.Pause) } }, + { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, + ]); + }); + }); + + describe('percent progress', () => { + it('should send progress events when boundaries reached', () => { + startMediaTracking({ + id, + boundaries: [10, 50, 90], + player: { duration: 100 }, + session: false, + }); + + trackMediaPlay({ id }); + for (let i = 1; i <= 100; i++) { + updateMediaTracking({ id, player: { currentTime: i } }); + } + + expect(eventQueue).toMatchObject([ + { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, + { + event: { + schema: getMediaEventSchema(MediaEventType.PercentProgress), + data: { percentProgress: 10 }, + }, + }, + { + event: { + schema: getMediaEventSchema(MediaEventType.PercentProgress), + data: { percentProgress: 50 }, + }, + }, + { + event: { + schema: getMediaEventSchema(MediaEventType.PercentProgress), + data: { percentProgress: 90 }, + }, + }, + ]); + }); + + it('doesnt send progress events if paused', () => { + startMediaTracking({ + id, + boundaries: [10, 50, 90], + player: { duration: 100 }, + session: false, + }); + + trackMediaPause({ id }); + for (let i = 1; i <= 100; i++) { + updateMediaTracking({ id, player: { currentTime: i } }); + } + + expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Pause) } }]); + }); + + it('doesnt send progress event multiple times', () => { + startMediaTracking({ + id, + boundaries: [50], + player: { duration: 100 }, + session: false, + }); + + trackMediaPlay({ id }); + for (let i = 1; i <= 100; i++) { + updateMediaTracking({ id, player: { currentTime: i } }); + } + trackMediaSeekEnd({ id, player: { currentTime: 0 } }); + for (let i = 1; i <= 100; i++) { + updateMediaTracking({ id, player: { currentTime: i } }); + } + trackMediaEnd({ id, player: { currentTime: 100 } }); + + expect(eventQueue).toMatchObject([ + { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, + { + event: { + schema: getMediaEventSchema(MediaEventType.PercentProgress), + data: { percentProgress: 50 }, + }, + }, + { + event: { + schema: getMediaEventSchema(MediaEventType.SeekEnd), + }, + context: [{ data: { currentTime: 0 } }], + }, + { event: { schema: getMediaEventSchema(MediaEventType.End) } }, + ]); + }); + }); +}); diff --git a/plugins/browser-plugin-element-tracking/test/pingInterval.test.ts b/plugins/browser-plugin-element-tracking/test/pingInterval.test.ts new file mode 100644 index 000000000..c13b2a6dd --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/pingInterval.test.ts @@ -0,0 +1,71 @@ +import { MediaPingInterval } from '../src/pingInterval'; + +describe('PingInterval', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.clearAllTimers(); + }); + + it('should fire every 30 seconds', () => { + let pings = 0; + new MediaPingInterval(undefined, undefined, () => pings++); + + for (let i = 0; i < 60; i++) { + jest.advanceTimersByTime(1000); + } + + expect(pings).toBe(2); + }); + + it('should fire in a custom interval', () => { + let pings = 0; + new MediaPingInterval(5, undefined, () => pings++); + + for (let i = 0; i < 20; i++) { + jest.advanceTimersByTime(1000); + } + + expect(pings).toBe(4); + }); + + it('should stop firing after clear', () => { + let pings = 0; + const interval = new MediaPingInterval(undefined, undefined, () => pings++); + + for (let i = 0; i < 30; i++) { + jest.advanceTimersByTime(1000); + } + + interval.clear(); + + for (let i = 0; i < 10; i++) { + jest.advanceTimersByTime(1000); + } + + expect(pings).toBe(1); + }); + + it('should stop firing ping events when paused', () => { + let pings = 0; + const interval = new MediaPingInterval(1, 3, () => pings++); + interval.update({ + currentTime: 0, + ended: false, + loop: false, + livestream: false, + muted: false, + paused: true, + playbackRate: 1, + volume: 100, + }); + + for (let i = 0; i < 30; i++) { + jest.advanceTimersByTime(1000); + } + + expect(pings).toBe(3); + }); +}); diff --git a/plugins/browser-plugin-element-tracking/test/sessionStats.test.ts b/plugins/browser-plugin-element-tracking/test/sessionStats.test.ts new file mode 100644 index 000000000..1987b46ef --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/sessionStats.test.ts @@ -0,0 +1,217 @@ +import { v4 as uuid } from 'uuid'; +import { MediaSessionTrackingStats } from '../src/sessionStats'; +import { MediaAdBreakType, MediaAdBreak, MediaEventType } from '../src/types'; + +const mediaPlayerDefaults = { + ended: false, + paused: false, +}; + +describe('MediaSessionTrackingStats', () => { + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.clearAllTimers(); + }); + + it('calculates played duration', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(60 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); + + let entity = session.toSessionContextEntity(); + expect(entity.contentWatched).toBe(61); + expect(entity.timePlayed).toBe(60); + expect(entity.timePlayedMuted).toBeUndefined(); + expect(entity.timePaused).toBeUndefined(); + expect(entity.avgPlaybackRate).toBeUndefined(); + }); + + it('considers pauses', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(10 * 1000); + session.update(undefined, { ...mediaPlayerDefaults, currentTime: 10 }); + session.update(MediaEventType.Pause, { ...mediaPlayerDefaults, currentTime: 10, paused: true }); + + jest.advanceTimersByTime(10 * 1000); + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 10 }); + + jest.advanceTimersByTime(50 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); + + let entity = session.toSessionContextEntity(); + expect(entity.contentWatched).toBe(61); + expect(entity.timePlayed).toBe(60); + expect(entity.timePlayedMuted).toBeUndefined(); + expect(entity.timePaused).toBe(10); + expect(entity.avgPlaybackRate).toBeUndefined(); + }); + + it('calculates play on mute', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0, muted: false }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.VolumeChange, { ...mediaPlayerDefaults, currentTime: 30, muted: true }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); + + let entity = session.toSessionContextEntity(); + expect(entity.contentWatched).toBe(61); + expect(entity.timePlayed).toBe(60); + expect(entity.timePlayedMuted).toBe(30); + expect(entity.timePaused).toBeUndefined(); + expect(entity.avgPlaybackRate).toBeUndefined(); + }); + + it('calculates average playback rate', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0, playbackRate: 1 }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.PlaybackRateChange, { + ...mediaPlayerDefaults, + currentTime: 30, + playbackRate: 2, + }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 90 }); + + let entity = session.toSessionContextEntity(); + expect(entity.contentWatched).toBe(91); + expect(entity.timePlayed).toBe(60); + expect(entity.timePlayedMuted).toBeUndefined(); + expect(entity.timePaused).toBeUndefined(); + expect(entity.avgPlaybackRate).toBe(1.5); + }); + + it('calculates stats for linear ads', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.AdStart, { ...mediaPlayerDefaults, currentTime: 30 }); + + jest.advanceTimersByTime(5 * 1000); + session.update(MediaEventType.AdClick, { ...mediaPlayerDefaults, currentTime: 30 }); + + jest.advanceTimersByTime(10 * 1000); + session.update(MediaEventType.AdComplete, { ...mediaPlayerDefaults, currentTime: 30 }); + + session.update(MediaEventType.AdStart, { ...mediaPlayerDefaults, currentTime: 30 }); + + jest.advanceTimersByTime(15 * 1000); + session.update(MediaEventType.AdComplete, { ...mediaPlayerDefaults, currentTime: 30 }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); + + let entity = session.toSessionContextEntity(); + expect(entity.timeSpentAds).toBe(30); + expect(entity.ads).toBe(2); + expect(entity.adsClicked).toBe(1); + expect(entity.adBreaks).toBeUndefined(); + expect(entity.contentWatched).toBe(61); + expect(entity.timePlayed).toBe(60); + }); + + it('calculate stats for non-linear ads', () => { + let session = new MediaSessionTrackingStats(); + let adBreak: MediaAdBreak = { breakId: uuid(), startTime: 0, breakType: MediaAdBreakType.NonLinear }; + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.AdBreakStart, { ...mediaPlayerDefaults, currentTime: 30 }, adBreak); + session.update(MediaEventType.AdStart, { ...mediaPlayerDefaults, currentTime: 30 }, adBreak); + + jest.advanceTimersByTime(15 * 1000); + session.update(MediaEventType.AdComplete, { ...mediaPlayerDefaults, currentTime: 45 }, adBreak); + session.update(MediaEventType.AdBreakEnd, { ...mediaPlayerDefaults, currentTime: 45 }, adBreak); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 75 }); + + let entity = session.toSessionContextEntity(); + expect(entity.timeSpentAds).toBe(15); + expect(entity.ads).toBe(1); + expect(entity.adBreaks).toBe(1); + expect(entity.contentWatched).toBe(76); + expect(entity.timePlayed).toBe(75); + }); + + it('counts rewatched content once in contentWatched', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.SeekStart, { ...mediaPlayerDefaults, currentTime: 30 }); + session.update(MediaEventType.SeekEnd, { ...mediaPlayerDefaults, currentTime: 15 }); + + jest.advanceTimersByTime(45 * 1000); + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); + + let entity = session.toSessionContextEntity(); + expect(entity.contentWatched).toBe(61); + expect(entity.timePlayed).toBe(75); + }); + + it('considers changes in ping events', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); + + for (let i = 0; i < 60; i++) { + session.update(MediaEventType.Ping, { ...mediaPlayerDefaults, currentTime: i, muted: i % 2 == 0 }); + jest.advanceTimersByTime(1 * 1000); + } + + session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); + + let entity = session.toSessionContextEntity(); + expect(entity.contentWatched).toBe(61); + expect(entity.timePlayed).toBe(60); + expect(entity.timePlayedMuted).toBe(30); + }); + + it('calculates buffering time', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.BufferStart, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(30 * 1000); + session.update(MediaEventType.BufferEnd, { ...mediaPlayerDefaults, currentTime: 0 }); + + let entity = session.toSessionContextEntity(); + expect(entity.timeBuffering).toBe(30); + }); + + it('ends buffering when playback time moves', () => { + let session = new MediaSessionTrackingStats(); + + session.update(MediaEventType.BufferStart, { ...mediaPlayerDefaults, currentTime: 0 }); + + jest.advanceTimersByTime(15 * 1000); + session.update(undefined, { ...mediaPlayerDefaults, currentTime: 1 }); + + jest.advanceTimersByTime(15 * 1000); + session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 15 }); + + let entity = session.toSessionContextEntity(); + expect(entity.timeBuffering).toBe(15); + }); +}); diff --git a/plugins/browser-plugin-element-tracking/tsconfig.json b/plugins/browser-plugin-element-tracking/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/rush.json b/rush.json index 338d6707b..fa04d33c5 100644 --- a/rush.json +++ b/rush.json @@ -421,6 +421,12 @@ "reviewCategory": "plugins", "versionPolicyName": "tracker" }, + { + "packageName": "@snowplow/browser-plugin-element-tracking", + "projectFolder": "plugins/browser-plugin-element-tracking", + "reviewCategory": "plugins", + "versionPolicyName": "tracker" + }, { "packageName": "@snowplow/browser-plugin-form-tracking", "projectFolder": "plugins/browser-plugin-form-tracking", From 96636e3534eae88f987c5f011c0cbfd9f677fcf3 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 15 Nov 2024 16:24:17 +1100 Subject: [PATCH 02/30] Further work on element tracking plugin --- .../index.html | 37 +- .../src/api.ts | 440 ++++++++++++------ .../src/components.ts | 42 ++ .../src/configuration.ts | 123 ++--- .../src/data.ts | 82 +++- .../src/elementsState.ts | 36 ++ .../src/schemata.ts | 6 +- .../src/util.ts | 23 + 8 files changed, 581 insertions(+), 208 deletions(-) create mode 100644 plugins/browser-plugin-element-tracking/src/components.ts create mode 100644 plugins/browser-plugin-element-tracking/src/elementsState.ts create mode 100644 plugins/browser-plugin-element-tracking/src/util.ts diff --git a/plugins/browser-plugin-element-tracking/index.html b/plugins/browser-plugin-element-tracking/index.html index 40629645b..e6518436b 100644 --- a/plugins/browser-plugin-element-tracking/index.html +++ b/plugins/browser-plugin-element-tracking/index.html @@ -25,13 +25,22 @@ 'snowplow' ); - + -
Some text content
+
This is: Some text content
A link +
This div will change
+

Recommended for You

  • Item AA
  • Item BB
  • @@ -41,6 +50,7 @@
+

Others also liked:

  • Item E
  • Item F
  • @@ -73,18 +83,33 @@ { attributes: ['class'] }, // attributes: get the static/default attributes originally defined on the element { properties: ['className'] }, // properties: get the dynamic/current attributes defined on the element { dataset: ['example'] }, // dataset: extract values from dataset attributes + { child_text: { heading: 'h2' } }, // child_text: for each given name:selector pair, extract the textContent of the first child matching selector, if it has text content use that value with the given name { selector: true }, // selector: attach the matching CSS selector as an attribute; useful if you're using logical names but want to differentiate { content: { textType: /text (\S+)/ } }, //content (map of regex patterns to match text against, first capture group used if detected) ], }, + { + selector: '.mutatetest.toggled', + name: 'mutated div', + create: true, + destroy: true, + expose: false, + obscure: false, + }, { selector: 'body', expose: false, component: true }, // expose is true by default; component means the name/selector is attached to the component_parents entity list for other events { name: 'recommendations', selector: '.recommendations', - expose: true, + expose: { + // expose has more options than the other events: + minTimeMillis: 5000, // cumulative time in milliseconds that each matching element should be visible for before considered exposed + minPercentage: 0, // the minimum percentage of the element's area that should be visible before considering exposed; range 0.0 - 1.0 + minSize: 0, // the minimum size the element should be before being considered expose; this can be used to ignore elements with 0 size + boundaryPixels: 0, // arbitrary margins to apply to the element when calculating minPercentage; can be a number to apply to all sides, 2-element array to specify vertical and horizontal, or 4-element array to specify margins for each size individually + }, obscure: true, component: true, - details: { attributes: ['class'] }, + details: { child_text: ['h2'] }, contents: [ // content information can be extracted { @@ -98,10 +123,12 @@ ], }); - snowplow('getComponentListGenerator', function (componentGenerator) { + snowplow('getComponentListGenerator', function (componentGenerator, componentGeneratorWithDetail) { // access a context generator aware of the startElementTracking configuration "components" snowplow('enableLinkClickTracking', { context: [componentGenerator] }); snowplow('enableFormTracking', { context: [componentGenerator] }); + + // componentGeneratorWithDetail will also populate element_detail entities for each component, but may not be directly compatible with the above APIs }); snowplow('trackPageView'); diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index b49728863..c5ede9a05 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -3,14 +3,25 @@ import { type BrowserTracker, dispatchToTrackersInCollection, } from '@snowplow/browser-tracker-core'; -import { type Logger, buildSelfDescribingEvent } from '@snowplow/tracker-core'; +import { type Logger, SelfDescribingJson, buildSelfDescribingEvent } from '@snowplow/tracker-core'; -import { checkConfig, type Configuration, ConfigurationState, type ElementConfiguration } from './configuration'; -import { extractSelectorDetails } from './data'; -import { ComponentsEntity, ElementContentEntity, Entities, Entity, Events, Event } from './schemata'; +import { baseComponentGenerator } from './components'; +import { + checkConfig, + type Configuration, + ConfigurationState, + type ContextProvider, + type ElementConfiguration, + Frequency, +} from './configuration'; +import { buildContentTree, evaluateDataSelector, getElementDetails } from './data'; +import { ElementStatus, elementsState, patchState } from './elementsState'; +import { ComponentsEntity, ElementDetailsEntity, Entity, Events, Event } from './schemata'; import type { OneOrMany } from './types'; +import { defineBoundaries, nodeIsElement } from './util'; type ElementTrackingConfiguration = { + context?: ContextProvider; elements: OneOrMany; }; @@ -24,14 +35,43 @@ const trackers: Record = {}; const configurations: Configuration[] = []; const configurationsById: Record = {}; +const WeakestSet = typeof WeakSet === 'undefined' ? Set : WeakSet; + +const trackedThisPage: Record> = { + [Events.ELEMENT_CREATE]: new Set(), + [Events.ELEMENT_DESTROY]: new Set(), + [Events.ELEMENT_EXPOSE]: new Set(), + [Events.ELEMENT_OBSCURE]: new Set(), +}; + +const trackedElements: Record> = { + [Events.ELEMENT_CREATE]: new WeakestSet(), + [Events.ELEMENT_DESTROY]: new WeakestSet(), + [Events.ELEMENT_EXPOSE]: new WeakestSet(), + [Events.ELEMENT_OBSCURE]: new WeakestSet(), +}; + +const trackedConfigs: Record> = { + [Events.ELEMENT_CREATE]: new WeakestSet(), + [Events.ELEMENT_DESTROY]: new WeakestSet(), + [Events.ELEMENT_EXPOSE]: new WeakestSet(), + [Events.ELEMENT_OBSCURE]: new WeakestSet(), +}; + let LOG: Logger | undefined = undefined; let mutationObserver: MutationObserver | false = false; let intersectionObserver: IntersectionObserver | false = false; /** - * Element Tracking plugin to track the (dis)appearance and visiblity of DOM elements. + * Element Tracking plugin to track the (dis)appearance and visibility of DOM elements. */ -export function SnowplowElementTrackingPlugin(): BrowserPlugin { +export function SnowplowElementTrackingPlugin({ ignoreNextPageView = true } = {}): BrowserPlugin { + if (ignoreNextPageView) { + Object.values(trackedThisPage).forEach((trackedThisPage) => { + trackedThisPage.add('initial'); + }); + } + return { activateBrowserPlugin(tracker) { trackers[tracker.id] = tracker; @@ -49,6 +89,17 @@ export function SnowplowElementTrackingPlugin(): BrowserPlugin { intersectionObserver || (typeof IntersectionObserver === 'function' && new IntersectionObserver(intersectionCallback)); }, + afterTrack(payload) { + if (payload['e'] === 'pv') { + Object.values(trackedThisPage).forEach((trackedThisPage) => { + if (trackedThisPage.has('initial')) { + trackedThisPage.delete('initial'); + } else { + trackedThisPage.clear(); + } + }); + } + }, logger(logger) { LOG = logger; }, @@ -63,7 +114,10 @@ export function SnowplowElementTrackingPlugin(): BrowserPlugin { * @param config Configuration for setting up media tracking * @param trackers The tracker identifiers which ping events will be sent to */ -export function startElementTracking({ elements = [] }: ElementTrackingConfiguration, trackers?: Array): void { +export function startElementTracking( + { elements = [], context }: ElementTrackingConfiguration, + trackers?: Array +): void { if (!mutationObserver || !intersectionObserver) { LOG?.error('ElementTracking plugin requires both MutationObserver and IntersectionObserver APIs'); if (mutationObserver) mutationObserver.disconnect(); @@ -76,7 +130,23 @@ export function startElementTracking({ elements = [] }: ElementTrackingConfigura configs.forEach((config) => { try { - const valid = checkConfig(config, trackers); + const contextMerger: ContextProvider = (element, config) => { + const result: SelfDescribingJson[] = []; + + for (const contextSrc of [context, config.context]) { + if (contextSrc) { + if (typeof contextSrc === 'function') { + if (contextSrc !== contextMerger) result.push(...contextSrc(element, config)); + } else { + result.push(...contextSrc); + } + } + } + + return result; + }; + + const valid = checkConfig(config, contextMerger, trackers); configurations.push(valid); if (valid.id) configurationsById[valid.id] = valid; } catch (e) { @@ -85,23 +155,27 @@ export function startElementTracking({ elements = [] }: ElementTrackingConfigura }); configurations.forEach((config) => { - const { create, expose, obscure, selector, state } = config; + const { expose, obscure, selector, state } = config; if (state === ConfigurationState.INITIAL) { config.state = ConfigurationState.CONFIGURED; - const elements = Array.from(document.querySelectorAll(selector)); + const elements = document.querySelectorAll(selector); - if (create && elements.length) { - // elements exist, that's a create - elements.forEach((element, i) => - trackEvent(Events.ELEMENT_CREATE, config, { element, position: i + 1, matches: elements.length }) + Array.from(elements, (element, i) => { + elementsState.set( + element, + patchState({ + lastPosition: i, + }) ); - config.state = ConfigurationState.CREATED; - } + elementsState.get(element)?.matches.add(config); - if (intersectionObserver && (expose || obscure)) { - elements.forEach((e) => intersectionObserver && intersectionObserver.observe(e)); - } + trackEvent(Events.ELEMENT_CREATE, config, element, { position: i + 1, matches: elements.length }); + + if (intersectionObserver && (expose || obscure)) { + intersectionObserver.observe(element); + } + }); } }); } @@ -145,179 +219,255 @@ export function endElementTracking(remove?: ElementTrackingDisable, trackers?: A } } -const componentGenerator = (...params: any[]): ComponentsEntity | null => { - const elementParams = params.filter((arg) => arg instanceof Node && nodeIsElement(arg)); - - if (!elementParams.length) return null; +const componentGenerator = baseComponentGenerator.bind(null, false, configurations) as ( + ...args: any[] +) => ComponentsEntity | null; +const detailedComponentGenerator = baseComponentGenerator.bind(null, true, configurations) as ( + ...args: any[] +) => [ComponentsEntity, ...ElementDetailsEntity[]] | null; + +export function getComponentListGenerator( + cb?: (basic: typeof componentGenerator, detailed: typeof detailedComponentGenerator) => void +): [typeof componentGenerator, typeof detailedComponentGenerator] { + if (cb) cb(componentGenerator, detailedComponentGenerator); + return [componentGenerator, detailedComponentGenerator]; +} - const components: string[] = []; +function shouldTrackExpose(config: Configuration, entry: IntersectionObserverEntry): boolean { + if (config.expose.when === Frequency.NEVER) return false; + if (!entry.isIntersecting) return false; - elementParams.forEach((elem) => { - configurations.forEach((config) => { - if (!config.component) return; + const { boundaryPixels, minPercentage, minSize } = config.expose; - const ancestor = elem.closest(config.selector); - if (ancestor !== null) components.push(config.name); - }); - }); + const { boundTop, boundRight, boundBottom, boundLeft } = defineBoundaries(boundaryPixels); - return components.length - ? { - schema: Entities.COMPONENT_PARENTS, - data: { - component_list: components, - }, - } - : null; -}; + const { intersectionRatio, boundingClientRect } = entry; + if (boundingClientRect.height * boundingClientRect.width < minSize) return false; + if (!(boundTop + boundRight + boundBottom + boundLeft)) { + if (minPercentage > intersectionRatio) return false; + } else { + const intersectionArea = entry.intersectionRect.height * entry.intersectionRect.width; + const boundingHeight = entry.boundingClientRect.height + boundTop + boundBottom; + const boundingWidth = entry.boundingClientRect.width + boundLeft + boundRight; + const boundingArea = boundingHeight * boundingWidth; + if (boundingArea && minPercentage > intersectionArea / boundingArea) return false; + } -export function getComponentListGenerator(fn?: (generator: typeof componentGenerator) => void) { - if (fn) fn(componentGenerator); - return componentGenerator; + return true; } function trackEvent( schema: T, config: Configuration, + element: Element | HTMLElement, options?: Partial<{ - element: Element | HTMLElement; boundingRect: DOMRect; position: number; matches: number; }> ): void { - const { element, boundingRect, position, matches } = options ?? {}; - - dispatchToTrackersInCollection(config.trackers, trackers, (tracker) => { - const payload: Event = { - schema, - data: { - element_name: config.name, - }, - }; - const event = buildSelfDescribingEvent({ event: payload }); - const context: Entity[] = []; - - if (element) { - if (config.details) { - const rect = boundingRect ?? element.getBoundingClientRect(); - context.push({ - schema: Entities.ELEMENT_DETAILS, - data: { - element_name: config.name, - width: rect.width, - height: rect.height, - position_x: rect.x, - position_y: rect.y, - position, - matches, - attributes: extractSelectorDetails(element, config.selector, config.details), - }, - }); - } + const { boundingRect, position, matches } = options ?? {}; - if (config.contents.length) { - context.push(...buildContentTree(config, element, position)); - } + // core payload + const payload: Event = { + schema, + data: { + element_name: config.name, + }, + }; + const event = buildSelfDescribingEvent({ event: payload }); + + // check custom conditions + const conditions = { + [Events.ELEMENT_CREATE]: config.create.condition, + [Events.ELEMENT_DESTROY]: config.destroy.condition, + [Events.ELEMENT_EXPOSE]: config.expose.condition, + [Events.ELEMENT_OBSCURE]: config.obscure.condition, + }; - const components = componentGenerator(element); - if (components) context.push(components); - } + if (conditions[schema]) { + if (!evaluateDataSelector(element, config.selector, conditions[schema]!).length) return; + } - if (config.aggregateStats) { - } + // check frequency caps + const frequencies = { + [Events.ELEMENT_CREATE]: config.create.when, + [Events.ELEMENT_DESTROY]: config.destroy.when, + [Events.ELEMENT_EXPOSE]: config.expose.when, + [Events.ELEMENT_OBSCURE]: config.obscure.when, + }; - tracker.core.track(event, context); - }); + switch (frequencies[schema]) { + case Frequency.NEVER: + return; // abort + case Frequency.ALWAYS: + break; // continue + case Frequency.ONCE: + if (trackedConfigs[schema].has(config)) return; // once / once per config + trackedConfigs[schema].add(config); + break; + case Frequency.ELEMENT: + if (trackedElements[schema].has(element)) return; // once per element + trackedElements[schema].add(element); + break; + case Frequency.PAGEVIEW: + if (trackedThisPage[schema].has(element)) return; // once per pageview + trackedThisPage[schema].add(element); + break; + } + + // build entities + const context: Entity[] = []; + + context.push(...(config.context(element, config) as Entity[])); + + if (config.details) { + context.push(getElementDetails(config, element, boundingRect, position, matches)); + } + + if (config.contents.length) { + context.push(...buildContentTree(config, element, position)); + } + + const components = detailedComponentGenerator(config.name, element); + if (components) context.push(...components); + + // track the event + setTimeout(dispatchToTrackersInCollection, 0, config.trackers, trackers, (tracker: BrowserTracker) => + tracker.core.track(event, context) + ); +} + +function handleCreate(nowTs: number, config: Configuration, node: Node | Element) { + if (nodeIsElement(node) && node.matches(config.selector)) { + elementsState.set( + node, + patchState({ + state: ElementStatus.CREATED, + createdTs: nowTs, + }) + ); + elementsState.get(node)?.matches.add(config); + trackEvent(Events.ELEMENT_CREATE, config, node); + if (config.expose.when !== Frequency.NEVER && intersectionObserver) intersectionObserver.observe(node); + } } function mutationCallback(mutations: MutationRecord[]): void { + const nowTs = performance.now() - performance.timeOrigin; mutations.forEach((record) => { - if (record.type === 'attributes') { - // TODO: what if existing node now matches selector? - } else if (record.type === 'childList') { - configurations.forEach((config) => { - record.addedNodes.forEach((node) => { - if (nodeIsElement(node) && node.matches(config.selector)) { - if (config.create) trackEvent(Events.ELEMENT_CREATE, config, { element: node }); - - config.state = ConfigurationState.CREATED; - if (config.expose && intersectionObserver) intersectionObserver.observe(node); + configurations.forEach((config) => { + const createFn = handleCreate.bind(null, nowTs, config); + + if (record.type === 'attributes') { + if (nodeIsElement(record.target)) { + const element = record.target; + const prevState = elementsState.get(element); + + if (prevState) { + if (!element.matches(config.selector)) { + if (prevState.matches.has(config)) { + if (prevState.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, element); + trackEvent(Events.ELEMENT_DESTROY, config, element); + prevState.matches.delete(config); + if (intersectionObserver) intersectionObserver.unobserve(element); + elementsState.set(element, patchState({ state: ElementStatus.DESTROYED }, prevState)); + } + } else { + if (!prevState.matches.has(config)) { + createFn(element); + } + } + } else { + createFn(element); } - }); + } + } else if (record.type === 'childList') { + record.addedNodes.forEach(createFn); record.removedNodes.forEach((node) => { if (nodeIsElement(node) && node.matches(config.selector)) { - if (config.obscure && config.state === ConfigurationState.EXPOSED) - trackEvent(Events.ELEMENT_OBSCURE, config, { element: node }); - if (config.destroy) trackEvent(Events.ELEMENT_DESTROY, config, { element: node }); - config.state = ConfigurationState.DESTROYED; + const state = elementsState.get(node) ?? patchState({}); + if (state.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, node); + trackEvent(Events.ELEMENT_DESTROY, config, node); + if (intersectionObserver) intersectionObserver.unobserve(node); + elementsState.set(node, patchState({ state: ElementStatus.DESTROYED }, state)); } }); - }); - } + } + }); }); } -function intersectionCallback(entries: IntersectionObserverEntry[]): void { +function intersectionCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver): void { entries.forEach((entry) => { + const state = elementsState.get(entry.target) ?? patchState({}); configurations.forEach((config) => { if (entry.target.matches(config.selector)) { const siblings = Array.from(document.querySelectorAll(config.selector)); const position = siblings.findIndex((el) => el.isSameNode(entry.target)) + 1; - if (entry.isIntersecting) { - if (config.expose) { - trackEvent(Events.ELEMENT_EXPOSE, config, { - element: entry.target, - boundingRect: entry.boundingClientRect, - position, - matches: siblings.length, - }); + const elapsedVisibleMs = [ElementStatus.PENDING, ElementStatus.EXPOSED].includes(state.state) + ? state.elapsedVisibleMs + (entry.time - state.lastObservationTs) + : state.elapsedVisibleMs; + elementsState.set( + entry.target, + patchState( + { + state: ElementStatus.PENDING, + lastObservationTs: entry.time, + elapsedVisibleMs, + }, + state + ) + ); + + if (shouldTrackExpose(config, entry)) { + if (config.expose.minTimeMillis <= state.elapsedVisibleMs) { + elementsState.set( + entry.target, + patchState( + { + state: ElementStatus.EXPOSED, + lastObservationTs: entry.time, + elapsedVisibleMs, + }, + state + ) + ); + trackEvent(Events.ELEMENT_EXPOSE, config, entry.target, { + boundingRect: entry.boundingClientRect, + position, + matches: siblings.length, + }); + } else { + requestAnimationFrame(() => { + // check visibility time next frame + observer.unobserve(entry.target); // observe is no-op for already observed elements + observer.observe(entry.target); + }); + } } - config.state = ConfigurationState.EXPOSED; - } else if (config.state !== ConfigurationState.DESTROYED) { - if (config.obscure) { - trackEvent(Events.ELEMENT_OBSCURE, config, { - element: entry.target, + } else if (state.state !== ElementStatus.DESTROYED) { + if (state.state === ElementStatus.EXPOSED) { + trackEvent(Events.ELEMENT_OBSCURE, config, entry.target, { boundingRect: entry.boundingClientRect, position, matches: siblings.length, }); } - config.state = ConfigurationState.OBSCURED; + elementsState.set( + entry.target, + patchState( + { + state: ElementStatus.OBSCURED, + lastObservationTs: entry.time, + }, + state + ) + ); } } }); }); } - -function buildContentTree(config: Configuration, element: Element, parentPosition: number = 1): ElementContentEntity[] { - const context: ElementContentEntity[] = []; - if (element && config.contents.length) { - config.contents.forEach((contentConfig) => { - const contents = Array.from(element.querySelectorAll(contentConfig.selector)); - - contents.forEach((contentElement, i) => { - context.push({ - schema: Entities.ELEMENT_CONTENT, - data: { - name: contentConfig.name, - parent_name: config.name, - parent_position: parentPosition, - position: i + 1, - attributes: extractSelectorDetails(contentElement, contentConfig.selector, contentConfig.details), - }, - }); - - context.push(...buildContentTree(contentConfig, contentElement, i + 1)); - }); - }); - } - - return context; -} - -function nodeIsElement(node: Node): node is Element { - return node.nodeType === Node.ELEMENT_NODE; -} diff --git a/plugins/browser-plugin-element-tracking/src/components.ts b/plugins/browser-plugin-element-tracking/src/components.ts new file mode 100644 index 000000000..dbef1c533 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/components.ts @@ -0,0 +1,42 @@ +import type { Configuration } from './configuration'; +import { getElementDetails } from './data'; +import { ComponentsEntity, ElementDetailsEntity, Entities } from './schemata'; +import { nodeIsElement } from './util'; + +export const baseComponentGenerator = ( + withDetails: boolean, + configurations: Configuration[], + ...params: any[] +): ComponentsEntity | [ComponentsEntity, ...ElementDetailsEntity[]] | null => { + const elementParams = params.filter((arg) => arg instanceof Node && nodeIsElement(arg)); + const elementName = params.find((arg) => typeof arg === 'string'); + + if (!elementParams.length) return null; + + const components: string[] = []; + const details: ElementDetailsEntity[] = []; + + elementParams.forEach((elem) => { + configurations.forEach((config) => { + if (!config.component) return; + + const ancestor = elem.closest(config.selector); + if (ancestor !== null) { + components.push(config.name); + if (withDetails && config.details) { + details.push(getElementDetails(config, ancestor)); + } + } + }); + }); + + const entity: ComponentsEntity = { + schema: Entities.COMPONENT_PARENTS, + data: { + element_name: elementName, + component_list: components, + }, + }; + + return components.length ? (withDetails ? [entity, ...details] : entity) : null; +}; diff --git a/plugins/browser-plugin-element-tracking/src/configuration.ts b/plugins/browser-plugin-element-tracking/src/configuration.ts index c3170709f..9a453b910 100644 --- a/plugins/browser-plugin-element-tracking/src/configuration.ts +++ b/plugins/browser-plugin-element-tracking/src/configuration.ts @@ -1,16 +1,18 @@ +import type { SelfDescribingJson } from '@snowplow/tracker-core'; + import { type DataSelector, isDataSelector } from './data'; import type { OneOrMany, RequiredExcept } from './types'; export enum ConfigurationState { INITIAL, CONFIGURED, - CREATED, - DESTROYED, - EXPOSED, - OBSCURED, } -enum Frequency { +export type ContextProvider = + | SelfDescribingJson[] + | ((element: Element | HTMLElement | undefined, match: Configuration) => SelfDescribingJson[]); + +export enum Frequency { ALWAYS = 'always', ELEMENT = 'element', ONCE = 'once', @@ -37,10 +39,10 @@ export type ElementConfiguration = { destroy?: boolean | BaseOptions; expose?: boolean | ExposeOptions; obscure?: boolean | BaseOptions; - aggregateStats?: boolean; component?: boolean; details?: OneOrMany; contents?: OneOrMany; + context?: ContextProvider; id?: string; }; @@ -49,19 +51,26 @@ export type Configuration = Omit< 'create' | 'destroy' | 'expose' | 'obscure' | 'details' | 'contents' > & { trackers?: string[]; - create: BaseOptions | null; - destroy: BaseOptions | null; - expose: RequiredExcept | null; - obscure: BaseOptions | null; + create: BaseOptions; + destroy: BaseOptions; + expose: RequiredExcept; + obscure: BaseOptions; state: ConfigurationState; details: DataSelector[]; contents: Configuration[]; + context: Extract; }; const DEFAULT_FREQUENCY_OPTIONS: BaseOptions = { when: 'always' }; -export function checkConfig(config: ElementConfiguration, trackers?: string[]): Configuration { - const { selector, name = selector, id, aggregateStats = false, component = false } = config; +const emptyProvider: ContextProvider = () => []; + +export function checkConfig( + config: ElementConfiguration, + contextProvider: ContextProvider, + trackers?: string[] +): Configuration { + const { selector, name = selector, id, component = false } = config; if (typeof name !== 'string' || !name) throw new Error(`Invalid element name value: ${name}`); if (typeof selector !== 'string' || !selector) throw new Error(`Invalid element selector value: ${selector}`); @@ -71,9 +80,9 @@ export function checkConfig(config: ElementConfiguration, trackers?: string[]): const { create = false, destroy = false, expose = true, obscure = false } = config; const [validCreate, validDestroy, validObscure] = [create, destroy, obscure].map((input) => { - if (!input) return null; + if (!input) return { when: Frequency.NEVER }; if (typeof input === 'object') { - const { when = 'never', condition } = input; + const { when = 'always', condition } = input; if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition'); @@ -91,47 +100,53 @@ export function checkConfig(config: ElementConfiguration, trackers?: string[]): let validExpose: RequiredExcept | null = null; - if (expose) { - if (typeof expose === 'object') { - const { - when = 'never', - condition, - boundaryPixels = 0, - minPercentage = 0, - minSize = 0, - minTimeMillis = 0, - } = expose; - - if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition'); - if ( - typeof boundaryPixels !== 'number' || - typeof minPercentage !== 'number' || - typeof minSize !== 'number' || - typeof minTimeMillis !== 'number' - ) - throw new Error('Invalid expose options provided'); - - if (when.toUpperCase() in Frequency) { - validExpose = { - when: when as Frequency, - condition, - boundaryPixels, - minPercentage, - minSize, - minTimeMillis, - }; - } else { - throw new Error(`Unknown tracking frequency: ${when}`); - } - } else { + if (expose && typeof expose === 'object') { + const { + when = 'always', + condition, + boundaryPixels = 0, + minPercentage = 0, + minSize = 0, + minTimeMillis = 0, + } = expose; + + if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition'); + if ( + (typeof boundaryPixels !== 'number' && !Array.isArray(boundaryPixels)) || + typeof minPercentage !== 'number' || + typeof minSize !== 'number' || + typeof minTimeMillis !== 'number' + ) + throw new Error('Invalid expose options provided'); + + if (when.toUpperCase() in Frequency) { validExpose = { - ...DEFAULT_FREQUENCY_OPTIONS, - boundaryPixels: 0, - minPercentage: 0, - minSize: 0, - minTimeMillis: 0, + when: when as Frequency, + condition, + boundaryPixels, + minPercentage, + minSize, + minTimeMillis, }; + } else { + throw new Error(`Unknown tracking frequency: ${when}`); } + } else if (expose) { + validExpose = { + ...DEFAULT_FREQUENCY_OPTIONS, + boundaryPixels: 0, + minPercentage: 0, + minSize: 0, + minTimeMillis: 0, + }; + } else { + validExpose = { + when: Frequency.NEVER, + boundaryPixels: 0, + minPercentage: 0, + minSize: 0, + minTimeMillis: 0, + }; } let { details = [], contents = [] } = config; @@ -147,10 +162,10 @@ export function checkConfig(config: ElementConfiguration, trackers?: string[]): destroy: validDestroy, expose: validExpose, obscure: validObscure, - aggregateStats: !!aggregateStats, component: !!component, details, - contents: contents.map((inner) => checkConfig(inner, trackers)), + contents: contents.map((inner) => checkConfig(inner, inner.context ?? emptyProvider, trackers)), + context: typeof contextProvider === 'function' ? contextProvider : () => contextProvider, trackers, state: ConfigurationState.INITIAL, }; diff --git a/plugins/browser-plugin-element-tracking/src/data.ts b/plugins/browser-plugin-element-tracking/src/data.ts index 4cc5c126a..3facf00a3 100644 --- a/plugins/browser-plugin-element-tracking/src/data.ts +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -1,3 +1,5 @@ +import type { Configuration } from './configuration'; +import { ElementContentEntity, ElementDetailsEntity, Entities } from './schemata'; import { AttributeList } from './types'; export type DataSelector = @@ -7,6 +9,7 @@ export type DataSelector = | { dataset: string[] } | { selector: boolean } | { content: Record } + | { child_text: Record } | { match: Record }; /** @@ -23,7 +26,7 @@ export function isDataSelector(val: unknown): val is DataSelector { : Exclude : Exclude; - const knownKeys = ['match', 'content', 'selector', 'dataset', 'attributes', 'properties'] as const; + const knownKeys = ['match', 'content', 'selector', 'dataset', 'attributes', 'properties', 'child_text'] as const; const selectorKeys: AllOf, typeof knownKeys> = knownKeys; // type error if we add a new selector without updating knownKeys if (typeof val === 'object') return selectorKeys.some((key) => key in val); @@ -42,7 +45,11 @@ export function extractSelectorDetails(element: Element, path: string, selectors }, []); } -function evaluateDataSelector(element: HTMLElement | Element, path: string, selector: DataSelector): AttributeList { +export function evaluateDataSelector( + element: HTMLElement | Element, + path: string, + selector: DataSelector +): AttributeList { const result: AttributeList = []; type DataSelectorType = (T extends T ? keyof T : never) | 'callback'; @@ -103,6 +110,16 @@ function evaluateDataSelector(element: HTMLElement | Element, path: string, sele }); } + source = 'child_text'; + if (source in selector && typeof selector[source] === 'object' && selector[source]) { + Object.entries(selector[source]).forEach(([attribute, selector]) => { + try { + const child = element.querySelector(selector); + if (child && child.textContent) result.push({ source, attribute, value: child.textContent }); + } catch (e) {} + }); + } + source = 'content'; if (source in selector && typeof selector[source] === 'object' && selector[source]) { Object.entries(selector[source]).forEach(([attribute, pattern]) => { @@ -131,5 +148,66 @@ function evaluateDataSelector(element: HTMLElement | Element, path: string, sele result.push({ source, attribute: source, value: path }); } + source = 'match'; + if (source in selector && selector[source]) { + const condition = selector[source]; + + for (const [attribute, value] of Object.entries(condition)) { + if (!result.some((r) => r.attribute === attribute && r.value === value)) return []; + } + } + return result; } + +export function buildContentTree( + config: Configuration, + element: Element, + parentPosition: number = 1 +): ElementContentEntity[] { + const context: ElementContentEntity[] = []; + if (element && config.contents.length) { + config.contents.forEach((contentConfig) => { + const contents = Array.from(element.querySelectorAll(contentConfig.selector)); + + contents.forEach((contentElement, i) => { + context.push({ + schema: Entities.ELEMENT_CONTENT, + data: { + element_name: contentConfig.name, + parent_name: config.name, + parent_position: parentPosition, + position: i + 1, + attributes: extractSelectorDetails(contentElement, contentConfig.selector, contentConfig.details), + }, + }); + + context.push(...buildContentTree(contentConfig, contentElement, i + 1)); + }); + }); + } + + return context; +} + +export function getElementDetails( + config: Configuration, + element: Element, + rect: DOMRect = element.getBoundingClientRect(), + position?: number, + matches?: number +): ElementDetailsEntity { + return { + schema: Entities.ELEMENT_DETAILS, + data: { + element_name: config.name, + width: rect.width, + height: rect.height, + position_x: rect.x, + position_y: rect.y, + position, + matches, + attributes: extractSelectorDetails(element, config.selector, config.details), + }, + }; +} diff --git a/plugins/browser-plugin-element-tracking/src/elementsState.ts b/plugins/browser-plugin-element-tracking/src/elementsState.ts new file mode 100644 index 000000000..637a7e995 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/elementsState.ts @@ -0,0 +1,36 @@ +import type { Configuration } from './configuration'; + +export enum ElementStatus { + INITIAL, + CREATED, + DESTROYED, + EXPOSED, + PENDING, + OBSCURED, +} + +type ElementState = { + state: ElementStatus; + createdTs: number; + lastObservationTs: number; + elapsedVisibleMs: number; + lastPosition: number; + matches: Set; +}; + +export const elementsState = + typeof WeakMap !== 'undefined' ? new WeakMap() : new Map(); + +export function patchState(updates: Partial, basis?: ElementState): ElementState { + const nowTs = performance.now(); + return { + state: ElementStatus.INITIAL, + matches: new Set(), + createdTs: nowTs + performance.timeOrigin, + lastPosition: -1, + lastObservationTs: nowTs, + elapsedVisibleMs: 0, + ...basis, + ...(updates || {}), + }; +} diff --git a/plugins/browser-plugin-element-tracking/src/schemata.ts b/plugins/browser-plugin-element-tracking/src/schemata.ts index 56439303c..dac7b803a 100644 --- a/plugins/browser-plugin-element-tracking/src/schemata.ts +++ b/plugins/browser-plugin-element-tracking/src/schemata.ts @@ -56,8 +56,9 @@ export type ElementContentEntity = SDJ< { parent_name: string; parent_position: number; - name: string; + element_name: string; position: number; + // TODO(jethron): matching_children? attributes?: AttributeList; } >; @@ -72,13 +73,14 @@ export type ElementDetailsEntity = SDJ< position_y: number; position?: number; matches?: number; - attributes: AttributeList; + attributes?: AttributeList; } >; export type ComponentsEntity = SDJ< Entities.COMPONENT_PARENTS, { + element_name?: string; component_list: string[]; } >; diff --git a/plugins/browser-plugin-element-tracking/src/util.ts b/plugins/browser-plugin-element-tracking/src/util.ts new file mode 100644 index 000000000..4258c9656 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/util.ts @@ -0,0 +1,23 @@ +export function nodeIsElement(node: Node): node is Element { + return node.nodeType === Node.ELEMENT_NODE; +} + +export function defineBoundaries(boundaryPixels: number | [number, number] | [number, number, number, number]) { + let boundTop: number, boundRight: number, boundBottom: number, boundLeft: number; + if (typeof boundaryPixels === 'number') { + boundTop = boundRight = boundBottom = boundLeft = boundaryPixels; + } else if (Array.isArray(boundaryPixels)) { + if (boundaryPixels.length === 2) { + boundTop = boundBottom = boundaryPixels[0]; + boundRight = boundLeft = boundaryPixels[1]; + } else if (boundaryPixels.length === 4) { + [boundTop, boundRight, boundBottom, boundLeft] = boundaryPixels; + } else { + boundTop = boundRight = boundBottom = boundLeft = 0; + } + } else { + boundTop = boundRight = boundBottom = boundLeft = 0; + } + + return { boundTop, boundRight, boundBottom, boundLeft }; +} From e9cc70e2fcd18a40ff3b0b1f97476e128f236797 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 15 Nov 2024 16:59:17 +1100 Subject: [PATCH 03/30] ShadowRoot support --- .../index.html | 24 +++++++++++++++++++ .../src/api.ts | 10 ++++---- .../src/configuration.ts | 8 +++++-- .../src/data.ts | 10 ++++---- .../src/util.ts | 23 +++++++++++++++--- 5 files changed, 61 insertions(+), 14 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/index.html b/plugins/browser-plugin-element-tracking/index.html index e6518436b..e22bbbac7 100644 --- a/plugins/browser-plugin-element-tracking/index.html +++ b/plugins/browser-plugin-element-tracking/index.html @@ -38,6 +38,7 @@
    This is: Some text content
    A link
    This div will change
    +

    Recommended for You

    @@ -59,6 +60,23 @@

    Others also liked:

+ + From 94684b4c681a8ebd131092208b128313fc354875 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 22 Nov 2024 17:14:20 +1100 Subject: [PATCH 05/30] Updates & docs --- .../src/api.ts | 225 +++++++++++++----- .../src/configuration.ts | 144 ++++++++++- .../src/data.ts | 63 ++++- .../src/elementsState.ts | 30 +++ .../src/schemata.ts | 1 - .../src/util.ts | 13 +- 6 files changed, 405 insertions(+), 71 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index e96d26207..eebb95e64 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -7,12 +7,13 @@ import { type Logger, SelfDescribingJson, buildSelfDescribingEvent } from '@snow import { baseComponentGenerator } from './components'; import { + ConfigurationState, + Frequency, checkConfig, + createContextMerger, type Configuration, - ConfigurationState, type ContextProvider, type ElementConfiguration, - Frequency, } from './configuration'; import { buildContentTree, evaluateDataSelector, getElementDetails } from './data'; import { ElementStatus, elementsState, patchState } from './elementsState'; @@ -20,20 +21,48 @@ import { ComponentsEntity, ElementDetailsEntity, Entity, Events, Event } from '. import type { OneOrMany } from './types'; import { defineBoundaries, getMatchingElements, nodeIsElement } from './util'; -type ElementTrackingConfiguration = { +/** + * Parameters for startElementTracking. + */ +export type ElementTrackingConfiguration = { + /** + * Optional context generator or static contexts to apply/add to any events generated by this batch of element configurations. + */ context?: ContextProvider; + /** + * Single or array of element configurations to start tracking events for. + */ elements: OneOrMany; }; -type ElementTrackingDisable = +/** + * Parameters for endElementTracking. + */ +export type ElementTrackingDisable = | { + /** + * A list of configuration names to stop tracking. Configurations can share names so this may match multiple cases. + * Configurations with no explicit name use their selector as their name. + */ elements: OneOrMany; } - | { elementIds: OneOrMany> }; + | { + /** + * A list of configuration IDs to stop tracking. Only a single Configuration is allowed to exist per ID so this can be used to target specific instances. + */ + elementIds: OneOrMany>; + } + | { + /** + * Custom predicate to return if each Configuration should be removed (`true`) or kept (`false`). + * @param configuration The filter function to decide if the Configuration should be removed or not. + * @returns + */ + filter: (configuration: Readonly) => boolean; + }; const trackers: Record = {}; const configurations: Configuration[] = []; -const configurationsById: Record = {}; const WeakestSet = typeof WeakSet === 'undefined' ? Set : WeakSet; @@ -63,9 +92,14 @@ let mutationObserver: MutationObserver | false = false; let intersectionObserver: IntersectionObserver | false = false; /** - * Element Tracking plugin to track the (dis)appearance and visibility of DOM elements. + * Plugin for tracking the addition and removal of elements to a page and the visibility of those elements. + * @param param0 Plugin configuration. + * @param param0.ignoreNextPageView Only required when use per-pageview frequency configurations and the ordering vs the pageview event matters. Defaults to `true`, which means the next pageview event will be ignored and not count as resetting the per-pageview state; this is correct if you're calling startElementTracking before calling trackPageView. + * @returns */ export function SnowplowElementTrackingPlugin({ ignoreNextPageView = true } = {}): BrowserPlugin { + // book keeping for controlling behavior if activated before/after first pageview + // used when tracking `when: pageview` frequency if (ignoreNextPageView) { Object.values(trackedThisPage).forEach((trackedThisPage) => { trackedThisPage.add('initial'); @@ -91,7 +125,9 @@ export function SnowplowElementTrackingPlugin({ ignoreNextPageView = true } = {} }, afterTrack(payload) { if (payload['e'] === 'pv') { + // re-set state for `when: pageview` frequency caps Object.values(trackedThisPage).forEach((trackedThisPage) => { + // handle book-keeping from above if (trackedThisPage.has('initial')) { trackedThisPage.delete('initial'); } else { @@ -107,12 +143,16 @@ export function SnowplowElementTrackingPlugin({ ignoreNextPageView = true } = {} } /** - * Starts element tracking for a single media content tracked in a media player. - * The tracking instance is uniquely identified by a given ID. - * All subsequent media track calls will be processed within this media tracking if given the same ID. + * Start Element tracking for elements that match the given configuration(s). * - * @param config Configuration for setting up media tracking - * @param trackers The tracker identifiers which ping events will be sent to + * Invalid configurations will be ignored, but valid configurations in the same batch will still apply. + * + * You can call this multiple times with different batches of configurations. E.g. section-specific configs, different custom context, different tracker instance destinations. + * Configurations supplied in multiple calls will not be deduped unless an `id` is provided and it collides with previous `id` values. + * + * @param param0 Element Tracking configuration options containing a batch of element configurations and optionally, custom context. + * @param trackers A list of tracker instance names that should receive events generated by this batch of element configurations. If not provided, events go to all trackers the plugin has activated for. + * @returns */ export function startElementTracking( { elements = [], context }: ElementTrackingConfiguration, @@ -126,29 +166,20 @@ export function startElementTracking( return; } - const configs = Array.isArray(elements) ? elements : [elements]; + const elementConfigs = Array.isArray(elements) ? elements : [elements]; + const merger = createContextMerger(context); - configs.forEach((config) => { + elementConfigs.forEach((config) => { try { - const contextMerger: ContextProvider = (element, config) => { - const result: SelfDescribingJson[] = []; - - for (const contextSrc of [context, config.context]) { - if (contextSrc) { - if (typeof contextSrc === 'function') { - if (contextSrc !== contextMerger) result.push(...contextSrc(element, config)); - } else { - result.push(...contextSrc); - } - } - } - - return result; - }; - - const valid = checkConfig(config, contextMerger, trackers); - configurations.push(valid); - if (valid.id) configurationsById[valid.id] = valid; + const valid = checkConfig(config, merger, trackers); + + // upsert by id if provided + if (valid.id) { + const existing = configurations.findIndex(({ id }) => id === valid.id); + if (existing > -1) { + configurations[existing] = valid; + } else configurations.push(valid); + } else configurations.push(valid); } catch (e) { LOG?.error('Failed to process Element Tracking configuration', e, config); } @@ -180,36 +211,49 @@ export function startElementTracking( }); } -export function endElementTracking(remove?: ElementTrackingDisable, trackers?: Array): void { +/** + * Stop tracking events for the configurations with the given names or IDs, or satisfying a custom predicate. + * If no parameters provided, removes all previous configurations. + * All element configurations have names, if not provided the `name` is the `selector` which is required. + * + * No considerations are made for tracker instance destinations. Use IDs or filters if you need specific routing options. + * @param remove Filter information for which element configurations should be removed. Omit to remove all configurations. + */ +export function endElementTracking(remove?: ElementTrackingDisable): void { if (!remove) { configurations.length = 0; - } + } else { + if ('elementIds' in remove) { + const { elementIds } = remove; + const idsToRemove = Array.isArray(elementIds) ? elementIds : [elementIds]; - if (remove && 'elementIds' in remove) { - const { elementIds } = remove; - const idsToRemove = Array.isArray(elementIds) ? elementIds : [elementIds]; + const remaining = configurations.filter(({ id }) => { + const shouldRemove = typeof id === 'string' && idsToRemove.includes(id); + return !shouldRemove; + }); - const remaining = configurations.filter((config) => { - const { id } = config; + configurations.splice(0, configurations.length, ...remaining); + } - const targeted = typeof id === undefined || !idsToRemove.includes(id!); - // TODO(jethron): remove for specific trackers - return targeted && !trackers; - }); - configurations.splice(0, configurations.length, ...remaining); - } + if ('elements' in remove) { + const { elements } = remove; + const elsToRemove = Array.isArray(elements) ? elements : [elements]; - if (remove && 'elements' in remove) { - const { elements } = remove; - const elsToRemove = Array.isArray(elements) ? elements : [elements]; + const remaining = configurations.filter(({ name }) => elsToRemove.includes(name)); - const remaining = configurations.filter((config) => { - const { name } = config; - const targeted = typeof name === undefined || !elsToRemove.includes(name); - // TODO(jethron): remove for specific trackers - return targeted && !trackers; - }); - configurations.splice(0, configurations.length, ...remaining); + configurations.splice(0, configurations.length, ...remaining); + } + + if ('filter' in remove && typeof remove.filter === 'function') { + const remaining = configurations.filter((config) => { + try { + return remove.filter(config) !== true; + } catch (e) { + return true; + } + }); + configurations.splice(0, configurations.length, ...remaining); + } } if (!configurations.length) { @@ -226,6 +270,24 @@ const detailedComponentGenerator = baseComponentGenerator.bind(null, true, confi ...args: any[] ) => [ComponentsEntity, ...ElementDetailsEntity[]] | null; +/** + * Obtain access to functions that can determine details about any configured `component`s and return `component_parents` context for given elements. + * + * It returns two generator functions: + * - one returns a single `component_parents` entity, which lists the names of any defined components that are ancestors of the provided element + * - the second returns multiple entities, including `component_parents` plus any element_details information about each matching component + * + * When called, the generators will examine all parameters in order looking for: + * - an element to look for owning components of + * - a string to use as the element_name in the component_parents entity + * + * The functions are suitable for use in Dynamic Context Generator functions used in other plugins such as Link and Form tracking. + * + * If a `cb` function is provided, it is called with the above generators as parameters that it can use in asynchronous situations (such as the JavaScript tracker). + * + * @param cb + * @returns Array of callbacks described above. + */ export function getComponentListGenerator( cb?: (basic: typeof componentGenerator, detailed: typeof detailedComponentGenerator) => void ): [typeof componentGenerator, typeof detailedComponentGenerator] { @@ -233,6 +295,10 @@ export function getComponentListGenerator( return [componentGenerator, detailedComponentGenerator]; } +/** + * Evaluates whether the current intersection is eligible for firing an EXPOSE event against the given configuration. + * Mostly this handles "disabled" config (when: never), minimum size/intersection checks and custom boundaries. + */ function shouldTrackExpose(config: Configuration, entry: IntersectionObserverEntry): boolean { if (config.expose.when === Frequency.NEVER) return false; if (!entry.isIntersecting) return false; @@ -256,6 +322,22 @@ function shouldTrackExpose(config: Configuration, entry: IntersectionObserverEnt return true; } +/** + * Do the thing! + * + * - Build the event payload for `schema` + * - Evaluate whether we have been configured to actually send that event + * - Evaluate frequency caps + * - Build entity payloads + * - Dispatch event to trackers + * + * The actual tracking is scheduled in a new task to yield back to the callers quickly, as they may need to be performant. + * @param schema The type of event to generate/track. + * @param config The configuration that matched and triggered this event to generate. This will include any criteria for the preventing the event to fire, and information for which entities are required. + * @param element The element that is the subject of the event; used to generate entity information. + * @param options Other details about the situation; like the bounding rect of the element, its number of siblings/etc. Some of these may be expensive to recalculate if needed, so you can provide them in advance if already available. + * @returns + */ function trackEvent( schema: T, config: Configuration, @@ -317,7 +399,7 @@ function trackEvent( } // build entities - const context: Entity[] = []; + const context: (Entity | SelfDescribingJson)[] = []; context.push(...(config.context(element, config) as Entity[])); @@ -338,6 +420,10 @@ function trackEvent( ); } +/** + * Handle some boilerplate/book-keeping to track an element as CREATEd. + * Saves use duplicating this logic many times in `mutationCallback`. + */ function handleCreate(nowTs: number, config: Configuration, node: Node | Element) { if (nodeIsElement(node) && node.matches(config.selector)) { elementsState.set( @@ -353,6 +439,16 @@ function handleCreate(nowTs: number, config: Configuration, node: Node | Element } } +/** + * Handler for the mutation observer. + * Checks for two mutation types: + * attributes: for existing nodes in the document that may mutate into matching a config when they didn't previously (or vice-versa) + * childList: for node adds/removals, to find new elements added dynamically to the page that may match configurations + * + * For the former, we need to keep track of if we've matched each element against a config before to determine if it's mutated away or not; not matching now isn't enough enough information to know if it previously matched. + * If we determine a matching element has been DESTROYed, we stop observing it for intersections. + * On the other hand, if a CREATE was determined, start observing intersections if that's requested in the configuration. + */ function mutationCallback(mutations: MutationRecord[]): void { const nowTs = performance.now() - performance.timeOrigin; mutations.forEach((record) => { @@ -398,6 +494,17 @@ function mutationCallback(mutations: MutationRecord[]): void { }); } +/** + * Handler for the intersection observer. + * Called when there are intersection updates, and when an element is first observed (the latter when new configs are added, or the mutation observer tries to evaluate visibility). + * + * Each entry is for a specific element, so we need to find if the element matches a config. + * With a config match we can determine if we need to fire EXPOSE/OBSCURE events for that element. + * For intersections, we first put it in a PENDING state in case we need to account for minimum time conditions. + * If time and other conditions are met, we can track the EXPOSE. + * Otherwise, we schedule an unobserve/reobserve in the next animation frame to update time in view, which will repeat until the time condition is met. This feels expensive, but seems to be OK since the layout should all be pretty freshly calculated at these points so it's actually pretty light. + * If no longer visible, consider tracking OBSCURE (unless it was DESTROYED, which would already have tried OBSCURE). + */ function intersectionCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver): void { entries.forEach((entry) => { const state = elementsState.get(entry.target) ?? patchState({}); @@ -421,7 +528,9 @@ function intersectionCallback(entries: IntersectionObserverEntry[], observer: In ) ); + // check configured criteria, if any if (shouldTrackExpose(config, entry)) { + // check time criteria if (config.expose.minTimeMillis <= state.elapsedVisibleMs) { elementsState.set( entry.target, @@ -440,8 +549,8 @@ function intersectionCallback(entries: IntersectionObserverEntry[], observer: In matches: siblings.length, }); } else { + // check visibility time next frame requestAnimationFrame(() => { - // check visibility time next frame observer.unobserve(entry.target); // observe is no-op for already observed elements observer.observe(entry.target); }); diff --git a/plugins/browser-plugin-element-tracking/src/configuration.ts b/plugins/browser-plugin-element-tracking/src/configuration.ts index f532b5772..7a257c7cd 100644 --- a/plugins/browser-plugin-element-tracking/src/configuration.ts +++ b/plugins/browser-plugin-element-tracking/src/configuration.ts @@ -8,46 +8,148 @@ export enum ConfigurationState { CONFIGURED, } +/** + * A dynamic context provider for events generated from the plugin. + * + * Can be a static list of Self Describing JSON entities, or a function that returns the same. + * The function will receive the matching element, and matching element configuration as parameters. + */ export type ContextProvider = | SelfDescribingJson[] | ((element: Element | HTMLElement | undefined, match: Configuration) => SelfDescribingJson[]); +/** + * When this type of event should actually be tracked after it has been detected. + */ export enum Frequency { + /** + * Track this event every time it occurs; e.g. EXPOSE every time it scrolls into/out of view. + */ ALWAYS = 'always', + /** + * Track this event at most once per element that matches the selector for the lifetime of the plugin. + */ ELEMENT = 'element', + /** + * Only track the event the first time it occurs per configuration. Even if other elements would trigger the event, ignore them after the first time it occurs. + */ ONCE = 'once', + /** + * Never track this event, effectively disabling a configuration. + */ NEVER = 'never', + /** + * Track each event only once per element until the next pageview is seen, allowing it to be tracked again. Mostly useful for Single Page Applications. + */ PAGEVIEW = 'pageview', } +/** + * Options controlling when this type of event should occur. + */ type BaseOptions = { + /** + * Frequency cap options for how often this should be tracked over the lifetime of the plugin. + */ when: `${Frequency}`; + /** + * A custom DataSelector defining if an element should trigger the event or not. If the DataSelector returns no result triplets, the event does not trigger. The `match` operation can be used here to do some logic against the other types of operators. + */ condition?: DataSelector; }; +/** + * Additional options for controlling when an EXPOSE event should occur. + */ type ExposeOptions = BaseOptions & { + /** + * For larger elements, only trigger if at least this proportion of the element is visible on screen; expects: 0.0 - 1.0; default: 0 + */ minPercentage?: number; + /** + * Only trigger once the element has been in view for at least this many milliseconds. The time is measured cumulatively. After the threshold is met it will re-fire immediately. + */ minTimeMillis?: number; + /** + * Don't count this element as visible unless its area (height * width) is at least this many pixels. Useful to prohibit empty container elements being tracked as visible. + */ minSize?: number; + /** + * Add these dimensions (in pixels) to the element size when calculating minPercentage. Used to increase/decrease the size of the actual element before considering it visible. + */ boundaryPixels?: [number, number, number, number] | [number, number] | number; }; +/** + * Input configuration format for describing a set of elements to be tracked with this plugin. + */ export type ElementConfiguration = { + /** + * Logical name for elements matched by this configuration. This name will be used to describe any matching elements in event payloads and entities, and to associate the data between them. + * If not provided, the `selector` is used as `name`. + */ name?: string; + /** + * Required. CSS selector to determine the set of elements that match this configuration. + */ selector: string; + /** + * If `selector` is intended for matching elements within custom elements or shadow DOM hosts, specify a selector for the shadow hosts here; this will be used to identify shadow-elements that match `selector` that would otherwise not be visible. + */ shadowSelector?: string; + /** + * If using `shadowSelector` to indicate `selector` matches elements in shadow hosts; use this to specify that only elements within shadow hosts matching `shadowSelector` should match; if `false` (default), elements outside shadow hosts (that are not necessarily children of `shadowSelector` hosts) will match the configuration also. + */ shadowOnly?: boolean; + /** + * Configure when, if ever, element create events should be triggered when detected for elements matching this configuration. + * Defaults to `false`, which is shorthand for `{ when: 'never' }`. + */ create?: boolean | BaseOptions; + /** + * Configure when, if ever, element destroy events should be triggered when detected for elements matching this configuration. + * Defaults to `false`, which is shorthand for `{ when: 'never' }`. + */ destroy?: boolean | BaseOptions; + /** + * Configure when, if ever, element expose events should be triggered when detected for elements matching this configuration. + * Also specify additional criteria on relevant for expose events like minimum size or visibility time. + * Defaults to `true`, which is shorthand for `{ when: 'always' }` and `0` for all other options. + */ expose?: boolean | ExposeOptions; + /** + * Configure when, if ever, element obscure events should be triggered when detected for elements matching this configuration. + * Defaults to `false`, which is shorthand for `{ when: 'never' }`. + */ obscure?: boolean | BaseOptions; + /** + * Indicate that elements matching this configuration are "components"; their ancestry of other elements will be identified in the component_parents entity if this is set (using this configuration's `name`). + */ component?: boolean; + /** + * When events occur to elements matching this configuration, extract data from one or more DataSelectors and include them in `attributes` in the `element` entity that describes that element. + */ details?: OneOrMany; + /** + * When events occur to elements matching this configuration, evaluate one or more nested configurations using the matching element as a root; the `name`, `selector`, `details`, and `contents` will be processed, with other options ignored. The resulting elements (and optionally their `details` will be included as entities on events for this element.) + */ contents?: OneOrMany; + /** + * Provide custom context entities for events generated from this configuration. + */ context?: ContextProvider; + /** + * An optional ID for this configuration. + * Calls to track configurations with a specific ID will override previous configurations with the same ID. + * No impact on actual tracking or payloads. + */ id?: string; }; +/** + * Parsed valid version of `ElementConfiguration`. + * Removes some ambiguities allowed in that type that are there for a more pleasant configuration API. + */ export type Configuration = Omit< RequiredExcept, 'create' | 'destroy' | 'expose' | 'obscure' | 'details' | 'contents' @@ -67,6 +169,36 @@ const DEFAULT_FREQUENCY_OPTIONS: BaseOptions = { when: 'always' }; const emptyProvider: ContextProvider = () => []; +/** + * Create a new ContextProvider that will merge the given `context` into that generated by the plugin or other configuration itself. + * @param context An existing ContextProvider to merge with future unknown context. + * @returns New ContextProvider function that will produce the results of merging its own context with the provided `context`. + */ +export function createContextMerger(context?: ContextProvider): ContextProvider { + return function contextMerger(element, config) { + const result: SelfDescribingJson[] = []; + + for (const contextSrc of [context, config.context]) { + if (contextSrc) { + if (typeof contextSrc === 'function') { + if (contextSrc !== contextMerger) result.push(...contextSrc(element, config)); + } else { + result.push(...contextSrc); + } + } + } + + return result; + }; +} + +/** + * Parse and validate a given `ElementConfiguration`, returning a more concrete `Configuration` if successful. + * @param config Input configuration to evaluate. + * @param contextProvider The context provider to embed into the configuration; this will handle merging any batch-level context into configuration-level context. + * @param trackers A list of trackers the resulting configuration should send events to, if specified. + * @returns Validated Configuration. + */ export function checkConfig( config: ElementConfiguration, contextProvider: ContextProvider, @@ -74,13 +206,16 @@ export function checkConfig( ): Configuration { const { selector, name = selector, shadowSelector, shadowOnly = false, id, component = false } = config; + // essential configs if (typeof name !== 'string' || !name) throw new Error(`Invalid element name value: ${name}`); if (typeof selector !== 'string' || !selector) throw new Error(`Invalid element selector value: ${selector}`); document.querySelector(config.selector); // this will throw if selector invalid + // event type frequencies & options const { create = false, destroy = false, expose = true, obscure = false } = config; + // simple event configs const [validCreate, validDestroy, validObscure] = [create, destroy, obscure].map((input) => { if (!input) return { when: Frequency.NEVER }; if (typeof input === 'object') { @@ -90,7 +225,7 @@ export function checkConfig( if (when.toUpperCase() in Frequency) { return { - when: when as Frequency, + when: when.toLowerCase() as Frequency, condition, }; } else { @@ -102,6 +237,7 @@ export function checkConfig( let validExpose: RequiredExcept | null = null; + // expose has custom options and is more complex if (expose && typeof expose === 'object') { const { when = 'always', @@ -123,7 +259,7 @@ export function checkConfig( if (when.toUpperCase() in Frequency) { validExpose = { - when: when as Frequency, + when: when.toLowerCase() as Frequency, condition, boundaryPixels, minPercentage, @@ -151,11 +287,15 @@ export function checkConfig( }; } + // normalize to arrays (scalars allowed in input for convenience) let { details = [], contents = [] } = config; if (!Array.isArray(details)) details = details == null ? [] : [details]; if (!Array.isArray(contents)) contents = contents == null ? [] : [contents]; + if (details.length !== details.filter(isDataSelector).length) + throw new Error('Invalid DataSelector given for details'); + return { name, selector, diff --git a/plugins/browser-plugin-element-tracking/src/data.ts b/plugins/browser-plugin-element-tracking/src/data.ts index 9900f101a..c089da386 100644 --- a/plugins/browser-plugin-element-tracking/src/data.ts +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -1,8 +1,24 @@ +import { SelfDescribingJson } from '@snowplow/tracker-core'; import type { Configuration } from './configuration'; import { ElementContentEntity, ElementDetailsEntity, Entities } from './schemata'; import { AttributeList } from './types'; import { getMatchingElements } from './util'; +/** + * A DataSelector defines information to extract from an element. + * + * It can be a custom function returning arbitrary key/values as an object. Non-string values will be cast to string or cast to JSON strings. If exceptions are thrown, the error information is captured. + * Alternatively there are several declarative options that can be specified as object properties. + * + * The properties allowed are: + * - attributes: Return the values of a list of attributes of the element + * - properties: Return the values of a list of property names of the element's DOM node; this is similar to attributes in some cases but different in others. E.g. `class` as an attribute needs to be `className` as a property; some attributes will reflect their initial value rather than what has been updated via JavaScript + * - dataset: Return the values of a list of data-* attributes; uses the camelCase name rather than the kebab-case name of the attribute + * - selector: Specify `true` to attach the CSS selector used to match the element; can be used to differentiate elements with the same `name` + * - content: Provide an object mapping names to RegExp patterns to run on the element's text content. If it matches it is included. The first group in the pattern will be prioritized if specified. + * - child_text: Provide an object mapping names to CSS selectors; the selectors are evaluated against the element and the first matching element's text content is the value. + * - match: Logical operator for use when used as a condition; always evaluated last. Look at existing matches so far, comparing each key/value to the provided object. Alternatively supply a predicate function that determines if this matches or not. If there are no matches, discard any matches found to this point. + */ export type DataSelector = | ((element: Element) => Record) | { attributes: string[] } @@ -11,7 +27,7 @@ export type DataSelector = | { selector: boolean } | { content: Record } | { child_text: Record } - | { match: Record }; + | { match: Record boolean)> }; /** * Type guard to determine if `val` is a valid `DataSelector` function or descriptor @@ -34,18 +50,32 @@ export function isDataSelector(val: unknown): val is DataSelector { return false; } +/** + * Combine the results of an array of DataSelectors + * @param element Element to select data from + * @param path The CSS path used to select `element`, which may be requested by the `selector` + * @param selectors The list of DataSelectors to evaluate + * @returns + */ export function extractSelectorDetails(element: Element, path: string, selectors: DataSelector[]): AttributeList { return selectors.reduce((attributes: AttributeList, selector) => { const result = evaluateDataSelector(element, path, selector); if (result.length) { return attributes.concat(result); - } + } else if ('match' in selector) return []; return attributes; }, []); } +/** + * Combine the results of an array of DataSelectors + * @param element Element to select data from + * @param path The CSS path used to select `element`, which may be requested by the `selector` DataSelector type + * @param selector The single DataSelector to evaluate + * @returns Selector results; list of key/value/source triplets that were extracted from the element + */ export function evaluateDataSelector( element: HTMLElement | Element, path: string, @@ -155,19 +185,32 @@ export function evaluateDataSelector( const condition = selector[source]; for (const [attribute, value] of Object.entries(condition)) { - if (!result.some((r) => r.attribute === attribute && r.value === value)) return []; + if ( + !result.some( + (r) => r.attribute === attribute && (typeof value === 'function' ? value(r.value) : r.value === value) + ) + ) + return []; } } return result; } +/** + * Builds a flat list of `element_content` entities describing the matched element and any child configuration matches. + * @param config A root/branch element configuration with nested `contents` configurations. + * @param element The element that matched `config` that will be (and have its children) described. + * @param parentPosition The position of this element amongst its matching sibling elements. Used to de-flatten the tree at analysis time. + * @returns A list of `element_content` entities describing the elements and its content, and the same of its children. + */ export function buildContentTree( config: Configuration, element: Element, parentPosition: number = 1 -): ElementContentEntity[] { - const context: ElementContentEntity[] = []; +): (ElementContentEntity | SelfDescribingJson)[] { + const context: (ElementContentEntity | SelfDescribingJson)[] = []; + if (element && config.contents.length) { config.contents.forEach((contentConfig) => { const contents = getMatchingElements(contentConfig, element); @@ -184,6 +227,7 @@ export function buildContentTree( }, }); + context.push(...contentConfig.context(contentElement, contentConfig)); context.push(...buildContentTree(contentConfig, contentElement, i + 1)); }); }); @@ -192,6 +236,15 @@ export function buildContentTree( return context; } +/** + * Builds an `element` entity. + * @param config + * @param element + * @param rect + * @param position + * @param matches + * @returns + */ export function getElementDetails( config: Configuration, element: Element, diff --git a/plugins/browser-plugin-element-tracking/src/elementsState.ts b/plugins/browser-plugin-element-tracking/src/elementsState.ts index 637a7e995..e46066891 100644 --- a/plugins/browser-plugin-element-tracking/src/elementsState.ts +++ b/plugins/browser-plugin-element-tracking/src/elementsState.ts @@ -9,18 +9,48 @@ export enum ElementStatus { OBSCURED, } +/** + * Keeps track of per-element stuff we need to keep track of. + */ type ElementState = { + /** + * Last known state for this element. Used to decide if events should be triggered or not when it's otherwise ambiguous. + */ state: ElementStatus; + /** + * When the element was first seen/deemed "created"; for use in aggregate stats in future version. + */ createdTs: number; + /** + * Last time we evaluated this element for a state change. Used for a delta to calculate cumulative visible time. + */ lastObservationTs: number; + /** + * The above mentioned cumulative visible time. + */ elapsedVisibleMs: number; + /** + * The last position we saw of this element amongst the other matches we saw for this element. + */ lastPosition: number; + /** + * The other matches for this element's selector we last saw, of which the element is/was at `lastPosition`-1 position. + */ matches: Set; }; +/** + * Bank of per-element state that needs to be stored. + */ export const elementsState = typeof WeakMap !== 'undefined' ? new WeakMap() : new Map(); +/** + * Create new element state to be stored later in `elementState` with sane defaults for unspecified state. + * @param updates Custom state to include in the updated state. + * @param basis The previous version of the state we want to be updating, rather than blank defaults. + * @returns New updated state accounding for default, basis, and requested updates. + */ export function patchState(updates: Partial, basis?: ElementState): ElementState { const nowTs = performance.now(); return { diff --git a/plugins/browser-plugin-element-tracking/src/schemata.ts b/plugins/browser-plugin-element-tracking/src/schemata.ts index dac7b803a..6a06d4c26 100644 --- a/plugins/browser-plugin-element-tracking/src/schemata.ts +++ b/plugins/browser-plugin-element-tracking/src/schemata.ts @@ -58,7 +58,6 @@ export type ElementContentEntity = SDJ< parent_position: number; element_name: string; position: number; - // TODO(jethron): matching_children? attributes?: AttributeList; } >; diff --git a/plugins/browser-plugin-element-tracking/src/util.ts b/plugins/browser-plugin-element-tracking/src/util.ts index 198c951cf..43feadbe0 100644 --- a/plugins/browser-plugin-element-tracking/src/util.ts +++ b/plugins/browser-plugin-element-tracking/src/util.ts @@ -20,15 +20,18 @@ export function defineBoundaries(boundaryPixels: number | [number, number] | [nu return { boundTop, boundRight, boundBottom, boundLeft }; } -export function getMatchingElements( - { selector, shadowOnly, shadowSelector }: Configuration, - target: ParentNode = document -) { +export function getMatchingElements(config: Configuration, target: ParentNode = document) { + const { selector, shadowOnly, shadowSelector } = config; const elements: Element[] = shadowOnly ? [] : Array.from(target.querySelectorAll(selector)); if (shadowSelector) { Array.from(target.querySelectorAll(shadowSelector), (host) => { - if (host.shadowRoot) elements.push(...Array.from(host.shadowRoot.querySelectorAll(selector))); + if (host.shadowRoot) { + // these will have been skipped in the above check but should be included if we're recursing + if (shadowOnly) elements.push(...Array.from(host.shadowRoot.querySelectorAll(selector))); + // look for nested shadow elements + elements.push(...getMatchingElements(config, host.shadowRoot)); + } }); } From 2d055d4a1a063e3e88bdf02c61df21c5de6aedc8 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Fri, 22 Nov 2024 17:15:29 +1100 Subject: [PATCH 06/30] Unit tests --- .../test/api.test.ts | 750 +----------------- .../test/components.test.ts | 42 + .../test/configuration.test.ts | 128 +++ .../test/data.test.ts | 138 ++++ .../test/pingInterval.test.ts | 71 -- .../test/sessionStats.test.ts | 217 ----- .../test/util.test.ts | 133 ++++ 7 files changed, 444 insertions(+), 1035 deletions(-) create mode 100644 plugins/browser-plugin-element-tracking/test/components.test.ts create mode 100644 plugins/browser-plugin-element-tracking/test/configuration.test.ts create mode 100644 plugins/browser-plugin-element-tracking/test/data.test.ts delete mode 100644 plugins/browser-plugin-element-tracking/test/pingInterval.test.ts delete mode 100644 plugins/browser-plugin-element-tracking/test/sessionStats.test.ts create mode 100644 plugins/browser-plugin-element-tracking/test/util.test.ts diff --git a/plugins/browser-plugin-element-tracking/test/api.test.ts b/plugins/browser-plugin-element-tracking/test/api.test.ts index 68803bb12..2fb539a0c 100644 --- a/plugins/browser-plugin-element-tracking/test/api.test.ts +++ b/plugins/browser-plugin-element-tracking/test/api.test.ts @@ -1,749 +1,5 @@ -import { addTracker, SharedState } from '@snowplow/browser-tracker-core'; -import { PayloadBuilder, SelfDescribingJson } from '@snowplow/tracker-core'; -import { - endMediaTracking, - SnowplowMediaPlugin, - startMediaTracking, - trackMediaAdBreakEnd, - trackMediaAdBreakStart, - trackMediaAdClick, - trackMediaAdComplete, - trackMediaAdFirstQuartile, - trackMediaAdMidpoint, - trackMediaAdPause, - trackMediaAdResume, - trackMediaAdSkip, - trackMediaAdStart, - trackMediaAdThirdQuartile, - trackMediaBufferEnd, - trackMediaBufferStart, - trackMediaEnd, - trackMediaError, - trackMediaFullscreenChange, - trackMediaPause, - trackMediaPictureInPictureChange, - trackMediaPlay, - trackMediaPlaybackRateChange, - trackMediaQualityChange, - trackMediaReady, - trackMediaSeekEnd, - trackMediaSeekStart, - trackMediaSelfDescribingEvent, - trackMediaVolumeChange, - updateMediaTracking, -} from '../src'; -import { getMediaEventSchema, MEDIA_PLAYER_SCHEMA, MEDIA_SESSION_SCHEMA } from '../src/schemata'; -import { MediaEventType } from '../src/types'; +import '../src'; -describe('Media Tracking API', () => { - let idx = 1; - let id = ''; - let eventQueue: { event: SelfDescribingJson; context: SelfDescribingJson[] }[] = []; - - beforeEach(() => { - addTracker(`sp${idx++}`, `sp${idx++}`, 'js-3.9.0', '', new SharedState(), { - stateStorageStrategy: 'cookie', - encodeBase64: false, - plugins: [ - SnowplowMediaPlugin(), - { - beforeTrack: (pb: PayloadBuilder) => { - const { ue_pr, co, tna } = pb.getPayload(); - if (tna == `sp${idx - 1}`) { - eventQueue.push({ event: JSON.parse(ue_pr as string).data, context: JSON.parse(co as string).data }); - } - }, - }, - ], - contexts: { webPage: false }, - }); - id = `media-${idx}`; - }); - - afterEach(() => { - endMediaTracking({ id }); - eventQueue = []; - }); - - describe('media player events', () => { - [ - { api: trackMediaReady, eventType: MediaEventType.Ready }, - { api: trackMediaPlay, eventType: MediaEventType.Play }, - { api: trackMediaPause, eventType: MediaEventType.Pause }, - { api: trackMediaEnd, eventType: MediaEventType.End }, - { api: trackMediaSeekStart, eventType: MediaEventType.SeekStart }, - { api: trackMediaSeekEnd, eventType: MediaEventType.SeekEnd }, - { api: trackMediaAdBreakStart, eventType: MediaEventType.AdBreakStart }, - { api: trackMediaAdBreakEnd, eventType: MediaEventType.AdBreakEnd }, - { api: trackMediaAdStart, eventType: MediaEventType.AdStart }, - { api: trackMediaAdComplete, eventType: MediaEventType.AdComplete }, - { api: trackMediaBufferStart, eventType: MediaEventType.BufferStart }, - { api: trackMediaBufferEnd, eventType: MediaEventType.BufferEnd }, - ].forEach((test) => { - it(`tracks a ${test.eventType} event`, () => { - startMediaTracking({ id, filterOutRepeatedEvents: false }); - - test.api({ id }); - - const { event } = eventQueue[0]; - - expect(event).toMatchObject({ - schema: getMediaEventSchema(test.eventType), - }); - }); - }); - - it('tracks a playback rate change event and remembers the new rate', () => { - startMediaTracking({ id, session: false, player: { playbackRate: 0.5 } }); - - trackMediaPlaybackRateChange({ id, newRate: 1.5 }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.PlaybackRateChange), - data: { - previousRate: 0.5, - newRate: 1.5, - }, - }, - context: [{ data: { playbackRate: 1.5 } }], - }, - { - context: [{ data: { playbackRate: 1.5 } }], - }, - ]); - }); - - it('tracks a volume change event and remembers the new volume', () => { - startMediaTracking({ id, session: false, player: { volume: 50 } }); - - trackMediaVolumeChange({ id, newVolume: 70 }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.VolumeChange), - data: { - previousVolume: 50, - newVolume: 70, - }, - }, - context: [{ data: { volume: 70 } }], - }, - { - context: [{ data: { volume: 70 } }], - }, - ]); - }); - - it('tracks a fullscreen change event and remembers the setting', () => { - startMediaTracking({ id, session: false }); - - trackMediaFullscreenChange({ id, fullscreen: true }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.FullscreenChange), - data: { fullscreen: true }, - }, - context: [{ data: { fullscreen: true } }], - }, - { - context: [{ data: { fullscreen: true } }], - }, - ]); - }); - - it('tracks a picture in picture change event and remembers the setting', () => { - startMediaTracking({ id, session: false }); - - trackMediaPictureInPictureChange({ id, pictureInPicture: true }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.PictureInPictureChange), - data: { pictureInPicture: true }, - }, - context: [{ data: { pictureInPicture: true } }], - }, - { - context: [{ data: { pictureInPicture: true } }], - }, - ]); - }); - - it('tracks an ad first quartile event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdFirstQuartile({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdFirstQuartile), - data: { percentProgress: 25 }, - }, - }, - ]); - }); - - it('tracks an ad midpoint event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdMidpoint({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdMidpoint), - data: { percentProgress: 50 }, - }, - }, - ]); - }); - - it('tracks an ad third quartile event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdThirdQuartile({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdThirdQuartile), - data: { percentProgress: 75 }, - }, - }, - ]); - }); - - it('tracks an ad skip event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdSkip({ id, percentProgress: 33.33 }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdSkip), - data: { percentProgress: 33 }, - }, - }, - ]); - }); - - it('tracks an ad click event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdClick({ id, percentProgress: 33.33 }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdClick), - data: { percentProgress: 33 }, - }, - }, - ]); - }); - - it('tracks an ad pause event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdPause({ id, percentProgress: 33.33 }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdPause), - data: { percentProgress: 33 }, - }, - }, - ]); - }); - - it('tracks an ad resume event', () => { - startMediaTracking({ id, session: false }); - - trackMediaAdResume({ id, percentProgress: 33.33 }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.AdResume), - data: { percentProgress: 33 }, - }, - }, - ]); - }); - - it('tracks quality change event and remembers the setting', () => { - startMediaTracking({ id, session: false, player: { quality: '720p' } }); - - trackMediaQualityChange({ - id, - newQuality: '1080p', - bitrate: 1000, - framesPerSecond: 30, - automatic: false, - }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.QualityChange), - data: { - previousQuality: '720p', - newQuality: '1080p', - bitrate: 1000, - framesPerSecond: 30, - automatic: false, - }, - }, - context: [{ data: { quality: '1080p' } }], - }, - { context: [{ data: { quality: '1080p' } }] }, - ]); - }); - - it('tracks error event', () => { - startMediaTracking({ id, session: false }); - - trackMediaError({ - id, - errorCode: '500', - errorName: 'forbidden', - errorDescription: 'Failed to load media', - }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.Error), - data: { - errorCode: '500', - errorName: 'forbidden', - errorDescription: 'Failed to load media', - }, - }, - }, - ]); - }); - - it('sets paused to false in media context when play is tracked', () => { - startMediaTracking({ id, player: { paused: true }, session: false }); - trackMediaPlay({ id }); - - expect(eventQueue).toMatchObject([ - { - context: [{ data: { paused: false } }], - }, - ]); - }); - - it('sets paused to true in media context when pause is tracked', () => { - startMediaTracking({ id, player: { paused: false }, session: false }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - context: [{ data: { paused: true } }], - }, - ]); - }); - - it('sets paused and ended to true in media context when end is tracked', () => { - startMediaTracking({ id, player: { paused: false }, session: false }); - trackMediaEnd({ id }); - - expect(eventQueue).toMatchObject([ - { - context: [{ data: { paused: true, ended: true } }], - }, - ]); - }); - - describe('filtering repeated events', () => { - it('doesnt track seek start and end multiple times', () => { - startMediaTracking({ id, player: { duration: 100 }, session: false }); - trackMediaSeekStart({ id, player: { currentTime: 1 } }); - trackMediaSeekEnd({ id, player: { currentTime: 2 } }); - trackMediaSeekStart({ id, player: { currentTime: 2 } }); - trackMediaSeekEnd({ id, player: { currentTime: 3 } }); - trackMediaSeekStart({ id, player: { currentTime: 3 } }); - trackMediaSeekEnd({ id, player: { currentTime: 4 } }); - trackMediaPlay({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { schema: getMediaEventSchema(MediaEventType.SeekStart) }, - context: [{ data: { currentTime: 1 } }], - }, - { - event: { schema: getMediaEventSchema(MediaEventType.SeekEnd) }, - context: [{ data: { currentTime: 4 } }], - }, - { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, - ]); - }); - - it('doesnt filter out repeated seek events when disabled', () => { - startMediaTracking({ id, filterOutRepeatedEvents: { seekEvents: false } }); - trackMediaSeekStart({ id, player: { currentTime: 1 } }); - trackMediaSeekEnd({ id, player: { currentTime: 2 } }); - trackMediaSeekStart({ id, player: { currentTime: 2 } }); - trackMediaSeekEnd({ id, player: { currentTime: 3 } }); - trackMediaSeekStart({ id, player: { currentTime: 3 } }); - trackMediaSeekEnd({ id, player: { currentTime: 4 } }); - - expect(eventQueue.length).toBe(6); - }); - - it('doesnt track volume change multiple times', () => { - startMediaTracking({ id, session: false }); - trackMediaVolumeChange({ id, newVolume: 50 }); - trackMediaVolumeChange({ id, newVolume: 60 }); - trackMediaVolumeChange({ id, newVolume: 70 }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.VolumeChange), - data: { - previousVolume: 60, - newVolume: 70, - }, - }, - }, - { event: { schema: getMediaEventSchema(MediaEventType.Pause) } }, - ]); - }); - - it('doesnt filter out repeated volume change events when disabled', () => { - startMediaTracking({ id, filterOutRepeatedEvents: { volumeChangeEvents: false } }); - trackMediaVolumeChange({ id, newVolume: 50 }); - trackMediaVolumeChange({ id, newVolume: 60 }); - trackMediaVolumeChange({ id, newVolume: 70 }); - trackMediaPause({ id }); - - expect(eventQueue.length).toBe(4); - }); - - it('flushes aggregated events on end tracking', () => { - startMediaTracking({ id, session: false }); - trackMediaVolumeChange({ id, newVolume: 50 }); - trackMediaVolumeChange({ id, newVolume: 60 }); - trackMediaVolumeChange({ id, newVolume: 70 }); - endMediaTracking({ id }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: getMediaEventSchema(MediaEventType.VolumeChange), - data: { - previousVolume: 60, - newVolume: 70, - }, - }, - }, - ]); - }); - - it('remembers context entities on flush', () => { - startMediaTracking({ id, session: false }); - trackMediaVolumeChange({ id, newVolume: 50 }); - trackMediaVolumeChange({ id, newVolume: 60 }); - trackMediaVolumeChange({ id, newVolume: 70, context: [{ schema: 'entity', data: {} }] }); - endMediaTracking({ id }); - - expect(eventQueue).toMatchObject([{ context: [{ schema: MEDIA_PLAYER_SCHEMA }, { schema: 'entity' }] }]); - }); - - it('flushes events that are waiting to be filtered automatically after timeout', (done) => { - startMediaTracking({ id, filterOutRepeatedEvents: { flushTimeoutMs: 0 } }); - trackMediaVolumeChange({ id, newVolume: 50 }); - - setTimeout(() => { - expect(eventQueue.length).toBe(1); - done(); - }, 0); - }); - }); - - it('adds custom context entities to all events', () => { - const context: Array = [{ schema: 'test', data: {} }]; - startMediaTracking({ id, context, session: false }); - - trackMediaPlay({ id }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([ - { context: [{ data: { paused: false } }, { schema: 'test' }] }, - { context: [{ data: { paused: true } }, { schema: 'test' }] }, - ]); - }); - - it('doesnt track events not in captureEvents', () => { - startMediaTracking({ id, captureEvents: [MediaEventType.Pause], session: false }); - - trackMediaPlay({ id }); - trackMediaPause({ id }); - - expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Pause) } }]); - }); - - it('tracks a custom self-describing event', () => { - startMediaTracking({ id }); - - trackMediaSelfDescribingEvent({ - id, - event: { - schema: 'iglu:com.acme/event/jsonschema/1-0-0', - data: { foo: 'bar' }, - }, - }); - - expect(eventQueue).toMatchObject([ - { - event: { - schema: 'iglu:com.acme/event/jsonschema/1-0-0', - data: { foo: 'bar' }, - }, - context: [{ schema: MEDIA_PLAYER_SCHEMA }, { schema: MEDIA_SESSION_SCHEMA }], - }, - ]); - }); - }); - - describe('session', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.clearAllTimers(); - }); - - it('adds media session context entity with given ID', () => { - startMediaTracking({ id }); - trackMediaReady({ id }); - - const { context, event } = eventQueue[0]; - - expect(context).toMatchObject([ - { - schema: MEDIA_PLAYER_SCHEMA, - }, - { - data: { mediaSessionId: id }, - schema: MEDIA_SESSION_SCHEMA, - }, - ]); - - expect(event).toMatchObject({ - schema: getMediaEventSchema(MediaEventType.Ready), - }); - }); - - it('adds media session context entity with given started at date', () => { - let startedAt = new Date(new Date().getTime() - 100 * 1000); - startMediaTracking({ id, session: { startedAt: startedAt } }); - trackMediaReady({ id }); - - const { context, event } = eventQueue[0]; - - expect(context).toMatchObject([ - { - schema: MEDIA_PLAYER_SCHEMA, - }, - { - data: { startedAt: startedAt.toISOString() }, - schema: MEDIA_SESSION_SCHEMA, - }, - ]); - - expect(event).toMatchObject({ - schema: getMediaEventSchema(MediaEventType.Ready), - }); - }); - - it('calculates session stats', () => { - startMediaTracking({ id, player: { duration: 10 } }); - trackMediaPlay({ id }); - jest.advanceTimersByTime(10 * 1000); - updateMediaTracking({ id, player: { currentTime: 10 } }); - trackMediaEnd({ id, player: { currentTime: 10 } }); - - expect(eventQueue).toMatchObject([ - { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, - { - event: { schema: getMediaEventSchema(MediaEventType.End) }, - context: [ - { schema: MEDIA_PLAYER_SCHEMA }, - { - schema: MEDIA_SESSION_SCHEMA, - data: { - timePlayed: 10, - contentWatched: 11, - }, - }, - ], - }, - ]); - }); - }); - - describe('ping events', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.clearAllTimers(); - }); - - it('starts sending ping events after session starts', () => { - startMediaTracking({ id, pings: true }); - - jest.advanceTimersByTime(30 * 1000); - - expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Ping) } }]); - }); - - it('should make a ping event in a custom interval', () => { - startMediaTracking({ id, pings: { pingInterval: 1 } }); - - jest.advanceTimersByTime(1000); - - expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Ping) } }]); - }); - - it('should send ping events regardless of other events', () => { - startMediaTracking({ id, pings: { pingInterval: 1, maxPausedPings: 10 } }); - trackMediaPlay({ id }); - jest.advanceTimersByTime(1000); - trackMediaPause({ id }); - jest.advanceTimersByTime(2000); - - expect(eventQueue).toMatchObject([ - { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, - { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, - { event: { schema: getMediaEventSchema(MediaEventType.Pause) } }, - { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, - { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, - ]); - }); - - it('should not send more ping events than max when paused', () => { - startMediaTracking({ id, pings: { pingInterval: 1, maxPausedPings: 1 } }); - trackMediaPause({ id }); - jest.advanceTimersByTime(1000); - jest.advanceTimersByTime(2000); - jest.advanceTimersByTime(3000); - - expect(eventQueue).toMatchObject([ - { event: { schema: getMediaEventSchema(MediaEventType.Pause) } }, - { event: { schema: getMediaEventSchema(MediaEventType.Ping) } }, - ]); - }); - }); - - describe('percent progress', () => { - it('should send progress events when boundaries reached', () => { - startMediaTracking({ - id, - boundaries: [10, 50, 90], - player: { duration: 100 }, - session: false, - }); - - trackMediaPlay({ id }); - for (let i = 1; i <= 100; i++) { - updateMediaTracking({ id, player: { currentTime: i } }); - } - - expect(eventQueue).toMatchObject([ - { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, - { - event: { - schema: getMediaEventSchema(MediaEventType.PercentProgress), - data: { percentProgress: 10 }, - }, - }, - { - event: { - schema: getMediaEventSchema(MediaEventType.PercentProgress), - data: { percentProgress: 50 }, - }, - }, - { - event: { - schema: getMediaEventSchema(MediaEventType.PercentProgress), - data: { percentProgress: 90 }, - }, - }, - ]); - }); - - it('doesnt send progress events if paused', () => { - startMediaTracking({ - id, - boundaries: [10, 50, 90], - player: { duration: 100 }, - session: false, - }); - - trackMediaPause({ id }); - for (let i = 1; i <= 100; i++) { - updateMediaTracking({ id, player: { currentTime: i } }); - } - - expect(eventQueue).toMatchObject([{ event: { schema: getMediaEventSchema(MediaEventType.Pause) } }]); - }); - - it('doesnt send progress event multiple times', () => { - startMediaTracking({ - id, - boundaries: [50], - player: { duration: 100 }, - session: false, - }); - - trackMediaPlay({ id }); - for (let i = 1; i <= 100; i++) { - updateMediaTracking({ id, player: { currentTime: i } }); - } - trackMediaSeekEnd({ id, player: { currentTime: 0 } }); - for (let i = 1; i <= 100; i++) { - updateMediaTracking({ id, player: { currentTime: i } }); - } - trackMediaEnd({ id, player: { currentTime: 100 } }); - - expect(eventQueue).toMatchObject([ - { event: { schema: getMediaEventSchema(MediaEventType.Play) } }, - { - event: { - schema: getMediaEventSchema(MediaEventType.PercentProgress), - data: { percentProgress: 50 }, - }, - }, - { - event: { - schema: getMediaEventSchema(MediaEventType.SeekEnd), - }, - context: [{ data: { currentTime: 0 } }], - }, - { event: { schema: getMediaEventSchema(MediaEventType.End) } }, - ]); - }); - }); +describe('Element Tracking Plugin API', () => { + it('passes', () => {}); }); diff --git a/plugins/browser-plugin-element-tracking/test/components.test.ts b/plugins/browser-plugin-element-tracking/test/components.test.ts new file mode 100644 index 000000000..39810c813 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/components.test.ts @@ -0,0 +1,42 @@ +import { baseComponentGenerator } from '../src/components'; +import { Entities } from '../src/schemata'; +import { Configuration } from '../src/configuration'; + +describe('component detection', () => { + let container: HTMLElement; + const config: Configuration = { + name: 'template', + selector: 'body', + shadowOnly: false, + component: true, + context: () => [], + trackers: [], + create: { when: 'never' }, + destroy: { when: 'never' }, + expose: { when: 'never', minPercentage: 0, boundaryPixels: 0, minSize: 0, minTimeMillis: 0 }, + obscure: { when: 'never' }, + state: 0, + details: [], + contents: [], + }; + + beforeAll(() => { + container = document.createElement('div'); + document.body.appendChild(container); + }); + + it('no-ops without target element', () => { + expect(baseComponentGenerator(true, [config])).toBeNull(); + }); + + it('finds components from child target', () => { + expect(baseComponentGenerator(false, [config], container)).toEqual({ + schema: Entities.COMPONENT_PARENTS, + data: { + component_list: [config.name], + }, + }); + + expect(baseComponentGenerator(true, [config], container)).toHaveLength(2); + }); +}); diff --git a/plugins/browser-plugin-element-tracking/test/configuration.test.ts b/plugins/browser-plugin-element-tracking/test/configuration.test.ts new file mode 100644 index 000000000..d22cbd3ea --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/configuration.test.ts @@ -0,0 +1,128 @@ +import { + Configuration, + type ContextProvider, + ElementConfiguration, + checkConfig, + createContextMerger, +} from '../src/configuration'; + +describe('configuration parsing', () => { + const emptyMerger = createContextMerger(); + + // Accept valid configs + it.each<{ note: string; input: ElementConfiguration; expected?: Partial }>([ + { + note: 'minimal configuration', + input: { selector: '.oncreate' }, + }, + { + note: 'use selector when no name', + input: { selector: 'unnamed' }, + expected: { selector: 'unnamed', name: 'unnamed' }, + }, + { + note: 'custom name', + input: { selector: '.selector', name: 'named' }, + expected: { selector: '.selector', name: 'named' }, + }, + { + note: 'default event frequencies', + input: { selector: 'unnamed' }, + expected: { + create: { when: 'never' }, + destroy: { when: 'never' }, + expose: { when: 'always', boundaryPixels: 0, minPercentage: 0, minSize: 0, minTimeMillis: 0 }, + obscure: { when: 'never' }, + component: false, + contents: [], + details: [], + }, + }, + { + note: 'custom frequencies', + input: { selector: 'unnamed', expose: false, create: true }, + expected: { + create: { when: 'always' }, + expose: { when: 'never', boundaryPixels: 0, minPercentage: 0, minSize: 0, minTimeMillis: 0 }, + }, + }, + { + note: 'mis-cased frequencies', + input: { selector: 'unnamed', create: { when: 'ALWAYs' as any }, expose: { when: 'Never' as any } }, + expected: { + create: { when: 'always' }, + expose: { when: 'never' } as any, + }, + }, + { + note: 'context provider', + input: { selector: 'unnamed', context: [] }, + expected: { + context: emptyMerger as Extract, + }, + }, + { + note: 'contents recursion', + input: { selector: 'unnamed', contents: { selector: 'inner' } }, + }, + ])('accepts valid config: $note', ({ input, expected }) => { + const result = checkConfig(input, emptyMerger); + expect(result).toBeDefined(); + if (expected) expect(result).toMatchObject(expected); + }); + + // Reject invalid configs + it.each<{ note: string; input: ElementConfiguration }>([ + { + note: 'invalid name', + input: { selector: 'a', name: '' }, + }, + { + note: 'empty selector', + input: { selector: '' }, + }, + { + note: 'invalid selector', + input: { selector: ':asdf' }, + }, + { + note: 'mistyped selector', + input: { selector: 1 as any }, + }, + { + note: 'good name, bad selector', + input: { selector: '', name: 'named' }, + }, + { + note: 'bad expose condition', + input: { selector: '.good', name: 'named', expose: { condition: {} } as any }, + }, + { + note: 'bad other condition', + input: { selector: '.good', name: 'named', create: { condition: true } as any }, + }, + { + note: 'bad expose frequency', + input: { selector: '.good', name: 'named', expose: { when: 'fail' } }, + }, + { + note: 'bad other frequency', + input: { selector: '.good', name: 'named', create: { when: 'fail' } }, + }, + { + note: 'bad boundaries', + input: { selector: '.good', name: 'named', expose: { when: 'always', boundaryPixels: '1em' } }, + }, + { + note: 'bad details request', + input: { selector: '.good', name: 'named', details: {} as any }, + }, + ])('rejects invalid config: $note', ({ input }) => { + expect.hasAssertions(); + try { + checkConfig(input, emptyMerger); + } catch (e) { + expect(e).toBeDefined(); + } + }); +}); diff --git a/plugins/browser-plugin-element-tracking/test/data.test.ts b/plugins/browser-plugin-element-tracking/test/data.test.ts new file mode 100644 index 000000000..2eae289a8 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/data.test.ts @@ -0,0 +1,138 @@ +import { DataSelector, evaluateDataSelector, extractSelectorDetails, isDataSelector } from '../src/data'; + +describe('DataSelector behavior', () => { + const fake_selector = 'css_path_val'; + let elem: HTMLDivElement; + + beforeAll(() => { + // sample element we'll be selecting from + elem = document.createElement('div'); + + elem.textContent = 'text_content_val\n'; + elem.setAttribute('id', 'id_attr'); + elem.className = 'class_prop'; + elem.dataset.dsAttr = 'ds_val'; + + const child = document.createElement('span'); + child.textContent = 'child_text_val'; + elem.appendChild(child); + }); + + it.each([ + ['attributes', ['id'], 'id_attr'], + ['properties', ['className'], 'class_prop'], + ['dataset', ['dsAttr'], 'ds_val'], + ['child_text', { inner: 'span' }, 'child_text_val'], + ['content', { inner: 'text_content\\S+' }, 'text_content_val'], + ['selector', true, fake_selector], + ])('extracts %p', (source, params, value) => { + const selector = { [source]: params } as DataSelector; + expect(isDataSelector(selector)).toBe(true); + + const result = evaluateDataSelector(elem, fake_selector, selector); + + const expected = Array.isArray(params) ? params : params === true ? [source] : Object.keys(params); + + expect(result).toEqual( + expected.map((attribute) => ({ + source, + attribute, + value, + })) + ); + }); + + it('handles callbacks', () => { + const sel = jest.fn().mockReturnValueOnce({ attribute: 'value', complex: { nested: 'data' } }); + + expect(isDataSelector(sel)).toBe(true); + + const result = evaluateDataSelector(elem, fake_selector, sel); + + expect(sel).toBeCalledWith(elem); + expect(result).toEqual([ + { + source: 'callback', + attribute: 'attribute', + value: 'value', + }, + { + source: 'callback', + attribute: 'complex', + value: '{"nested":"data"}', + }, + ]); + }); + + it('handles callback failures', () => { + const sel = jest.fn, Element[]>(() => { + throw new Error('deliberate failure'); + }); + + expect(isDataSelector(sel)).toBe(true); + + const result = evaluateDataSelector(elem, fake_selector, sel); + + expect(sel).toBeCalledWith(elem); + expect(result).toEqual([ + { + source: 'error', + attribute: 'message', + value: 'deliberate failure', + }, + ]); + }); + + it('flattens correctly', () => { + const result = extractSelectorDetails(elem, fake_selector, [ + { attributes: ['id'] }, + { properties: ['className'] }, + { properties: ['DOES_NOT_EXIST'] }, + ]); + + expect(result).toHaveLength(2); + }); + + it('doesnt mix properties and attributes', () => { + const result = evaluateDataSelector(elem, fake_selector, { + attributes: ['className', 'class'], + properties: ['className', 'class'], + }); + + expect(result).toHaveLength(2); + + expect(result).toContainEqual({ + source: 'properties', + attribute: 'className', + value: 'class_prop', + }); + + expect(result).toContainEqual({ + source: 'attributes', + attribute: 'class', + value: 'class_prop', + }); + }); + + it('produces successful matches', () => { + const result = evaluateDataSelector(elem, fake_selector, { + attributes: ['id'], + match: { id: 'id_attr' }, + }); + + expect(result).toHaveLength(1); + }); + + it('filters unsuccessful matches', () => { + const result = evaluateDataSelector(elem, fake_selector, { + attributes: ['id'], + match: { id: 'NOT_REAL_VALUE' }, + }); + + expect(result).toHaveLength(0); + }); + + it.each([null, {}, { notASelector: [] }])('ignores empty: %j', (invalid) => { + expect(isDataSelector(invalid)).toBe(false); + }); +}); diff --git a/plugins/browser-plugin-element-tracking/test/pingInterval.test.ts b/plugins/browser-plugin-element-tracking/test/pingInterval.test.ts deleted file mode 100644 index c13b2a6dd..000000000 --- a/plugins/browser-plugin-element-tracking/test/pingInterval.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { MediaPingInterval } from '../src/pingInterval'; - -describe('PingInterval', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.clearAllTimers(); - }); - - it('should fire every 30 seconds', () => { - let pings = 0; - new MediaPingInterval(undefined, undefined, () => pings++); - - for (let i = 0; i < 60; i++) { - jest.advanceTimersByTime(1000); - } - - expect(pings).toBe(2); - }); - - it('should fire in a custom interval', () => { - let pings = 0; - new MediaPingInterval(5, undefined, () => pings++); - - for (let i = 0; i < 20; i++) { - jest.advanceTimersByTime(1000); - } - - expect(pings).toBe(4); - }); - - it('should stop firing after clear', () => { - let pings = 0; - const interval = new MediaPingInterval(undefined, undefined, () => pings++); - - for (let i = 0; i < 30; i++) { - jest.advanceTimersByTime(1000); - } - - interval.clear(); - - for (let i = 0; i < 10; i++) { - jest.advanceTimersByTime(1000); - } - - expect(pings).toBe(1); - }); - - it('should stop firing ping events when paused', () => { - let pings = 0; - const interval = new MediaPingInterval(1, 3, () => pings++); - interval.update({ - currentTime: 0, - ended: false, - loop: false, - livestream: false, - muted: false, - paused: true, - playbackRate: 1, - volume: 100, - }); - - for (let i = 0; i < 30; i++) { - jest.advanceTimersByTime(1000); - } - - expect(pings).toBe(3); - }); -}); diff --git a/plugins/browser-plugin-element-tracking/test/sessionStats.test.ts b/plugins/browser-plugin-element-tracking/test/sessionStats.test.ts deleted file mode 100644 index 1987b46ef..000000000 --- a/plugins/browser-plugin-element-tracking/test/sessionStats.test.ts +++ /dev/null @@ -1,217 +0,0 @@ -import { v4 as uuid } from 'uuid'; -import { MediaSessionTrackingStats } from '../src/sessionStats'; -import { MediaAdBreakType, MediaAdBreak, MediaEventType } from '../src/types'; - -const mediaPlayerDefaults = { - ended: false, - paused: false, -}; - -describe('MediaSessionTrackingStats', () => { - beforeAll(() => { - jest.useFakeTimers(); - }); - - afterAll(() => { - jest.clearAllTimers(); - }); - - it('calculates played duration', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(60 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); - - let entity = session.toSessionContextEntity(); - expect(entity.contentWatched).toBe(61); - expect(entity.timePlayed).toBe(60); - expect(entity.timePlayedMuted).toBeUndefined(); - expect(entity.timePaused).toBeUndefined(); - expect(entity.avgPlaybackRate).toBeUndefined(); - }); - - it('considers pauses', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(10 * 1000); - session.update(undefined, { ...mediaPlayerDefaults, currentTime: 10 }); - session.update(MediaEventType.Pause, { ...mediaPlayerDefaults, currentTime: 10, paused: true }); - - jest.advanceTimersByTime(10 * 1000); - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 10 }); - - jest.advanceTimersByTime(50 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); - - let entity = session.toSessionContextEntity(); - expect(entity.contentWatched).toBe(61); - expect(entity.timePlayed).toBe(60); - expect(entity.timePlayedMuted).toBeUndefined(); - expect(entity.timePaused).toBe(10); - expect(entity.avgPlaybackRate).toBeUndefined(); - }); - - it('calculates play on mute', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0, muted: false }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.VolumeChange, { ...mediaPlayerDefaults, currentTime: 30, muted: true }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); - - let entity = session.toSessionContextEntity(); - expect(entity.contentWatched).toBe(61); - expect(entity.timePlayed).toBe(60); - expect(entity.timePlayedMuted).toBe(30); - expect(entity.timePaused).toBeUndefined(); - expect(entity.avgPlaybackRate).toBeUndefined(); - }); - - it('calculates average playback rate', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0, playbackRate: 1 }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.PlaybackRateChange, { - ...mediaPlayerDefaults, - currentTime: 30, - playbackRate: 2, - }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 90 }); - - let entity = session.toSessionContextEntity(); - expect(entity.contentWatched).toBe(91); - expect(entity.timePlayed).toBe(60); - expect(entity.timePlayedMuted).toBeUndefined(); - expect(entity.timePaused).toBeUndefined(); - expect(entity.avgPlaybackRate).toBe(1.5); - }); - - it('calculates stats for linear ads', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.AdStart, { ...mediaPlayerDefaults, currentTime: 30 }); - - jest.advanceTimersByTime(5 * 1000); - session.update(MediaEventType.AdClick, { ...mediaPlayerDefaults, currentTime: 30 }); - - jest.advanceTimersByTime(10 * 1000); - session.update(MediaEventType.AdComplete, { ...mediaPlayerDefaults, currentTime: 30 }); - - session.update(MediaEventType.AdStart, { ...mediaPlayerDefaults, currentTime: 30 }); - - jest.advanceTimersByTime(15 * 1000); - session.update(MediaEventType.AdComplete, { ...mediaPlayerDefaults, currentTime: 30 }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); - - let entity = session.toSessionContextEntity(); - expect(entity.timeSpentAds).toBe(30); - expect(entity.ads).toBe(2); - expect(entity.adsClicked).toBe(1); - expect(entity.adBreaks).toBeUndefined(); - expect(entity.contentWatched).toBe(61); - expect(entity.timePlayed).toBe(60); - }); - - it('calculate stats for non-linear ads', () => { - let session = new MediaSessionTrackingStats(); - let adBreak: MediaAdBreak = { breakId: uuid(), startTime: 0, breakType: MediaAdBreakType.NonLinear }; - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.AdBreakStart, { ...mediaPlayerDefaults, currentTime: 30 }, adBreak); - session.update(MediaEventType.AdStart, { ...mediaPlayerDefaults, currentTime: 30 }, adBreak); - - jest.advanceTimersByTime(15 * 1000); - session.update(MediaEventType.AdComplete, { ...mediaPlayerDefaults, currentTime: 45 }, adBreak); - session.update(MediaEventType.AdBreakEnd, { ...mediaPlayerDefaults, currentTime: 45 }, adBreak); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 75 }); - - let entity = session.toSessionContextEntity(); - expect(entity.timeSpentAds).toBe(15); - expect(entity.ads).toBe(1); - expect(entity.adBreaks).toBe(1); - expect(entity.contentWatched).toBe(76); - expect(entity.timePlayed).toBe(75); - }); - - it('counts rewatched content once in contentWatched', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.SeekStart, { ...mediaPlayerDefaults, currentTime: 30 }); - session.update(MediaEventType.SeekEnd, { ...mediaPlayerDefaults, currentTime: 15 }); - - jest.advanceTimersByTime(45 * 1000); - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); - - let entity = session.toSessionContextEntity(); - expect(entity.contentWatched).toBe(61); - expect(entity.timePlayed).toBe(75); - }); - - it('considers changes in ping events', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 0 }); - - for (let i = 0; i < 60; i++) { - session.update(MediaEventType.Ping, { ...mediaPlayerDefaults, currentTime: i, muted: i % 2 == 0 }); - jest.advanceTimersByTime(1 * 1000); - } - - session.update(MediaEventType.End, { ...mediaPlayerDefaults, currentTime: 60 }); - - let entity = session.toSessionContextEntity(); - expect(entity.contentWatched).toBe(61); - expect(entity.timePlayed).toBe(60); - expect(entity.timePlayedMuted).toBe(30); - }); - - it('calculates buffering time', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.BufferStart, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(30 * 1000); - session.update(MediaEventType.BufferEnd, { ...mediaPlayerDefaults, currentTime: 0 }); - - let entity = session.toSessionContextEntity(); - expect(entity.timeBuffering).toBe(30); - }); - - it('ends buffering when playback time moves', () => { - let session = new MediaSessionTrackingStats(); - - session.update(MediaEventType.BufferStart, { ...mediaPlayerDefaults, currentTime: 0 }); - - jest.advanceTimersByTime(15 * 1000); - session.update(undefined, { ...mediaPlayerDefaults, currentTime: 1 }); - - jest.advanceTimersByTime(15 * 1000); - session.update(MediaEventType.Play, { ...mediaPlayerDefaults, currentTime: 15 }); - - let entity = session.toSessionContextEntity(); - expect(entity.timeBuffering).toBe(15); - }); -}); diff --git a/plugins/browser-plugin-element-tracking/test/util.test.ts b/plugins/browser-plugin-element-tracking/test/util.test.ts new file mode 100644 index 000000000..31c389986 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/util.test.ts @@ -0,0 +1,133 @@ +import { defineBoundaries, getMatchingElements, nodeIsElement } from '../src/util'; +import { Configuration } from '../src/configuration'; + +const TEMPLATE_CONFIG: Configuration = { + name: 'template', + selector: '', + shadowOnly: false, + component: false, + context: () => [], + trackers: [], + create: { when: 'never' }, + destroy: { when: 'never' }, + expose: { when: 'never', minPercentage: 0, boundaryPixels: 0, minSize: 0, minTimeMillis: 0 }, + obscure: { when: 'never' }, + state: 0, + details: [], + contents: [], +}; + +describe('utils', () => { + describe('defineBoundaries', () => { + it.each([ + [0, [0, 0, 0, 0]], + [1, [1, 1, 1, 1]], + [[0, 1] as [number, number], [0, 1, 0, 1]], + [[2, 2, 2, 2] as [number, number, number, number], [2, 2, 2, 2]], + [[2, 2, 2] as any as [number, number, number, number], [0, 0, 0, 0]], // silent error + [{} as any as number, [0, 0, 0, 0]], // silent error + ])('interprets: %j', (provided, expected) => { + const [boundTop, boundRight, boundBottom, boundLeft] = expected; + expect(defineBoundaries(provided)).toEqual({ + boundTop, + boundRight, + boundBottom, + boundLeft, + }); + }); + }); + + describe('getMatchingElements', () => { + it('queries for nodes', () => { + const config: Configuration = { ...TEMPLATE_CONFIG, selector: 'body' }; + const results = getMatchingElements(config); + expect(results).toHaveLength(1); + expect(results[0]).toBe(document.body); + expect(nodeIsElement(results[0])).toBe(true); + + const explicitTarget = getMatchingElements(config, document); + expect(results).toEqual(explicitTarget); + }); + + describe('shadow host support', () => { + const elements: Element[] = []; + const config: Configuration = { ...TEMPLATE_CONFIG, selector: 'span' }; + + beforeAll(() => { + const host = document.createElement('div'); + host.id = 'shadowHost'; + const shadow = host.attachShadow({ mode: 'open' }); + document.body.appendChild(host); + + for (let i = 5; i > 0; i--) { + const el = document.createElement('span'); + shadow.appendChild(el); + elements.push(el); + } + }); + + afterAll(() => { + document.body.replaceChildren(); + }); + + it('should not find from shadow roots by default', () => { + expect(getMatchingElements(config)).toHaveLength(0); + }); + + it('should descend shadow hosts matching shadowSelector', () => { + const shadowConfig: Configuration = { + ...config, + shadowSelector: '#shadowHost', + }; + + expect(getMatchingElements(shadowConfig)).toEqual(elements); + }); + + it('should respect shadowOnly setting', () => { + const shadowConfig: Configuration = { + ...config, + shadowSelector: '#shadowHost', + shadowOnly: false, + }; + + let results = getMatchingElements(shadowConfig); + + expect(results).toEqual(elements); + + // with shadowOnly: false, should find a non-shadow span, too + const addition = document.createElement('span'); + document.body.appendChild(addition); + + results = getMatchingElements(shadowConfig); + expect(results).not.toEqual(elements); + expect(results).toHaveLength(elements.length + 1); + expect(results).toEqual(elements.concat([addition])); + + // with shadowOnly: true, should ignore the non-shadow span + results = getMatchingElements({ ...shadowConfig, shadowOnly: true }); + expect(results).toEqual(elements); + }); + + it('should find nested shadow targets', () => { + const shadowConfig: Configuration = { + ...config, + shadowSelector: '#shadowHost', + shadowOnly: true, + }; + + let results = getMatchingElements(shadowConfig); + + expect(results).toEqual(elements); + + const host = document.querySelector('#shadowHost')!.shadowRoot!; + const addition = document.createElement('span'); + host.appendChild(addition); + + results = getMatchingElements(shadowConfig); + expect(results).not.toEqual(elements); + expect(results).toHaveLength(elements.length + 1); + expect(results).toEqual(elements.concat([addition])); + }); + }); + }); +}); From d9ec02c79d359dc11191f889f6ca1dddcba34628 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Tue, 26 Nov 2024 16:36:21 +1100 Subject: [PATCH 07/30] More doc updates --- .../browser-plugin-element-tracking/README.md | 10 ++++++++-- .../browser-plugin-element-tracking/src/api.ts | 2 +- .../src/components.ts | 8 ++++++++ .../browser-plugin-element-tracking/src/data.ts | 14 +++++++------- .../browser-plugin-element-tracking/src/util.ts | 17 +++++++++++++++++ 5 files changed, 41 insertions(+), 10 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/README.md b/plugins/browser-plugin-element-tracking/README.md index 770b47c8b..8e57a6bdc 100644 --- a/plugins/browser-plugin-element-tracking/README.md +++ b/plugins/browser-plugin-element-tracking/README.md @@ -30,16 +30,22 @@ npm install @snowplow/browser-plugin-element-tracking ## Usage -Initialize your tracker with the SnowplowMediaPlugin: +Initialize your tracker with the SnowplowElementTrackingPlugin and then call `startElementTracking`: ```js import { newTracker } from '@snowplow/browser-tracker'; -import { SnowplowElementTrackingPlugin } from '@snowplow/browser-plugin-element-tracking'; +import { SnowplowElementTrackingPlugin, startElementTracking } from '@snowplow/browser-plugin-element-tracking'; newTracker('sp1', '{{collector_url}}', { appId: 'my-app-id', plugins: [ SnowplowElementTrackingPlugin() ], }); + +startElementTracking({ + elements: [ + {selector: '.newsletter-signup'} + ] +}); ``` For a full API reference, you can read the plugin [documentation page](https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/browser-tracker/browser-tracker-v3-reference/plugins/element-tracking/). diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index eebb95e64..efea48992 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -285,7 +285,7 @@ const detailedComponentGenerator = baseComponentGenerator.bind(null, true, confi * * If a `cb` function is provided, it is called with the above generators as parameters that it can use in asynchronous situations (such as the JavaScript tracker). * - * @param cb + * @param cb Callback function to receive the generator callbacks described above asynchronously. * @returns Array of callbacks described above. */ export function getComponentListGenerator( diff --git a/plugins/browser-plugin-element-tracking/src/components.ts b/plugins/browser-plugin-element-tracking/src/components.ts index dbef1c533..9c8e9fdb6 100644 --- a/plugins/browser-plugin-element-tracking/src/components.ts +++ b/plugins/browser-plugin-element-tracking/src/components.ts @@ -3,6 +3,14 @@ import { getElementDetails } from './data'; import { ComponentsEntity, ElementDetailsEntity, Entities } from './schemata'; import { nodeIsElement } from './util'; +/** + * Generic callback for providing `component` entities for given Elements. + * Auto discovers element and name from parameters and returns the list of components that encapsulate that element. + * @param withDetails Whether details for each component should be included. + * @param configurations List of configurations that contain component definitions. + * @param params Arbitrary parameters that should include an element and optionally a logical name for that element. + * @returns `component_parents` entity, or if `withDetails` specified, a list of entities containing `component_parents` and the details for each component as `element` entities. + */ export const baseComponentGenerator = ( withDetails: boolean, configurations: Configuration[], diff --git a/plugins/browser-plugin-element-tracking/src/data.ts b/plugins/browser-plugin-element-tracking/src/data.ts index c089da386..41786d3ec 100644 --- a/plugins/browser-plugin-element-tracking/src/data.ts +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -55,7 +55,7 @@ export function isDataSelector(val: unknown): val is DataSelector { * @param element Element to select data from * @param path The CSS path used to select `element`, which may be requested by the `selector` * @param selectors The list of DataSelectors to evaluate - * @returns + * @returns Flattened list of results found processing the element. */ export function extractSelectorDetails(element: Element, path: string, selectors: DataSelector[]): AttributeList { return selectors.reduce((attributes: AttributeList, selector) => { @@ -238,12 +238,12 @@ export function buildContentTree( /** * Builds an `element` entity. - * @param config - * @param element - * @param rect - * @param position - * @param matches - * @returns + * @param config Configuration describing any additional data that should be included in the entity. + * @param element The elment this entity will describe. + * @param rect The position/dimension information of the element. + * @param position Which match this element is amongst those that match the Configuration's selector. + * @param matches The total number, including this one, of elements that matched the Configuration selector. + * @returns The Element entity SDJ. */ export function getElementDetails( config: Configuration, diff --git a/plugins/browser-plugin-element-tracking/src/util.ts b/plugins/browser-plugin-element-tracking/src/util.ts index 43feadbe0..ee19ebd46 100644 --- a/plugins/browser-plugin-element-tracking/src/util.ts +++ b/plugins/browser-plugin-element-tracking/src/util.ts @@ -1,5 +1,10 @@ import { Configuration } from './configuration'; +/** + * Parses custom boundaries config. + * @param boundaryPixels Input format boundaries. + * @returns Object describing the input boundary configuration. + */ export function defineBoundaries(boundaryPixels: number | [number, number] | [number, number, number, number]) { let boundTop: number, boundRight: number, boundBottom: number, boundLeft: number; if (typeof boundaryPixels === 'number') { @@ -20,6 +25,13 @@ export function defineBoundaries(boundaryPixels: number | [number, number] | [nu return { boundTop, boundRight, boundBottom, boundLeft }; } +/** + * Gets descendent elements of `target` (`document` by default) that match the selector info in `config`. + * This is usually a wrapper around `querySelectorAll`, but accounts for shadow roots that can be specified in `config`. + * @param config The configuration containing the selector and other details that influence how elements will be found. + * @param target The root to start finding descendent elements from; defaults to `document` + * @returns Array of elements within `target` that matched the configuration in `config`. + */ export function getMatchingElements(config: Configuration, target: ParentNode = document) { const { selector, shadowOnly, shadowSelector } = config; const elements: Element[] = shadowOnly ? [] : Array.from(target.querySelectorAll(selector)); @@ -38,6 +50,11 @@ export function getMatchingElements(config: Configuration, target: ParentNode = return elements; } +/** + * Type guard to check that `Node` is an `Element` + * @param node Node to check. + * @returns If `node` is an Element. + */ export function nodeIsElement(node: Node): node is Element { return node.nodeType === Node.ELEMENT_NODE; } From 0fe6142f4e7418f12a3f7b403503d22fbf5448be Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Wed, 27 Nov 2024 18:45:43 +1100 Subject: [PATCH 08/30] Fix demo page --- plugins/browser-plugin-element-tracking/index.html | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/index.html b/plugins/browser-plugin-element-tracking/index.html index 9acd39a1b..def5d0cf3 100644 --- a/plugins/browser-plugin-element-tracking/index.html +++ b/plugins/browser-plugin-element-tracking/index.html @@ -80,9 +80,16 @@

Others also liked:

- - -
This is: Some text content
- A link -
This div will change
-
This one too, but tracked once per pageview
- - -
-

Recommended for You

-
    -
  • Item AA
  • -
  • Item BB
  • -
  • Item CC
  • -
  • Item DD
  • -
-
- -
-

Others also liked:

-
    -
  • Item E
  • -
  • Item F
  • -
  • Item G
  • -
  • Item H
  • -
-
- - - - - - - From 57eafada24d60ec0c41c9437fc64d9d45d0e512c Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Wed, 11 Dec 2024 15:24:03 +1100 Subject: [PATCH 23/30] Run rush change --- .../plugin-element-tracking_2024-12-11-04-22.json | 10 ++++++++++ .../plugin-element-tracking_2024-12-11-04-22.json | 10 ++++++++++ 2 files changed, 20 insertions(+) create mode 100644 common/changes/@snowplow/browser-plugin-element-tracking/plugin-element-tracking_2024-12-11-04-22.json create mode 100644 common/changes/@snowplow/javascript-tracker/plugin-element-tracking_2024-12-11-04-22.json diff --git a/common/changes/@snowplow/browser-plugin-element-tracking/plugin-element-tracking_2024-12-11-04-22.json b/common/changes/@snowplow/browser-plugin-element-tracking/plugin-element-tracking_2024-12-11-04-22.json new file mode 100644 index 000000000..d2be62c6c --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-element-tracking/plugin-element-tracking_2024-12-11-04-22.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-element-tracking", + "comment": "Create element tracking plugin", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-element-tracking" +} \ No newline at end of file diff --git a/common/changes/@snowplow/javascript-tracker/plugin-element-tracking_2024-12-11-04-22.json b/common/changes/@snowplow/javascript-tracker/plugin-element-tracking_2024-12-11-04-22.json new file mode 100644 index 000000000..f111409f8 --- /dev/null +++ b/common/changes/@snowplow/javascript-tracker/plugin-element-tracking_2024-12-11-04-22.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/javascript-tracker", + "comment": "Add support for element tracking plugin", + "type": "none" + } + ], + "packageName": "@snowplow/javascript-tracker" +} \ No newline at end of file From 3f57ff83ebeca20a4f3c9717727a69bb8241d581 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Wed, 11 Dec 2024 17:25:15 +1100 Subject: [PATCH 24/30] Fix package.json version --- plugins/browser-plugin-element-tracking/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/package.json b/plugins/browser-plugin-element-tracking/package.json index 1638a3372..01219c7ac 100644 --- a/plugins/browser-plugin-element-tracking/package.json +++ b/plugins/browser-plugin-element-tracking/package.json @@ -1,6 +1,6 @@ { "name": "@snowplow/browser-plugin-element-tracking", - "version": "3.24.4", + "version": "4.1.0", "description": "Snowplow element tracking", "homepage": "https://github.com/snowplow/snowplow-javascript-tracker", "bugs": "https://github.com/snowplow/snowplow-javascript-tracker/issues", @@ -48,6 +48,6 @@ "typescript": "~4.6.2" }, "peerDependencies": { - "@snowplow/browser-tracker": "~3.24.4" + "@snowplow/browser-tracker": "~4.1.0" } } From ba623c38592b6ac9fabfc4d686ae9d2ea74fc13f Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Thu, 12 Dec 2024 17:29:15 +1100 Subject: [PATCH 25/30] Make tests v4 compatible --- .../jest.config.js | 1 + .../test/api.test.ts | 35 ++++++++++++------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/jest.config.js b/plugins/browser-plugin-element-tracking/jest.config.js index bd3ea4e2a..90f99fb5b 100644 --- a/plugins/browser-plugin-element-tracking/jest.config.js +++ b/plugins/browser-plugin-element-tracking/jest.config.js @@ -2,4 +2,5 @@ module.exports = { preset: 'ts-jest', reporters: ['jest-standard-reporter'], testEnvironment: 'jest-environment-jsdom-global', + setupFilesAfterEnv: ['../../setupTestGlobals.ts'], }; diff --git a/plugins/browser-plugin-element-tracking/test/api.test.ts b/plugins/browser-plugin-element-tracking/test/api.test.ts index 3fbcc44ed..f1c10359e 100644 --- a/plugins/browser-plugin-element-tracking/test/api.test.ts +++ b/plugins/browser-plugin-element-tracking/test/api.test.ts @@ -31,11 +31,11 @@ const inNewTask = (cb: () => void) => const eventSDJ = ( field: string, - evt: { evt: Record }, + evt: { evt: Record }, schema: string ): Record | Record[] | undefined => { try { - const payloadString = evt.evt[field]; + const payloadString = evt.evt[field] as string; const payload = JSON.parse(payloadString); if (/^ue_p[rx]$/.test(field)) { @@ -53,8 +53,8 @@ const entityOf = eventSDJ.bind(null, 'co'); const FAKE_CONTEXT_SCHEMA = 'iglu:com.example/custom_entity/jsonschema/1-0-0'; describe('Element Tracking Plugin API', () => { - let eventQueue: { evt: Record }[]; - let secondEventQueue: { evt: Record }[]; + const eventQueue: { evt: Record }[] = []; + const secondEventQueue: { evt: Record }[] = []; let warnLog: jest.SpyInstance>; let tracker: BrowserTracker; @@ -68,7 +68,15 @@ describe('Element Tracking Plugin API', () => { tracker = addTracker('test', 'test', 'js-test', '', state, { stateStorageStrategy: 'cookie', encodeBase64: false, - plugins: [plugin], + plugins: [ + plugin, + { + beforeTrack: (pb) => { + eventQueue.push({ evt: pb.build() }); + }, + }, + ], + customFetch: async () => new Response(null, { status: 200 }), })!; expect(activateBrowserPlugin).toHaveBeenCalled(); @@ -77,12 +85,18 @@ describe('Element Tracking Plugin API', () => { addTracker('test2', 'test2', 'js-test', '', state, { stateStorageStrategy: 'cookie', encodeBase64: false, - plugins: [plugin], + plugins: [ + plugin, + { + beforeTrack: (pb) => { + secondEventQueue.push({ evt: pb.build() }); + }, + }, + ], + customFetch: async () => new Response(null, { status: 200 }), }); warnLog = jest.spyOn(logger.mock.calls[0][0], 'warn'); - eventQueue = state.outQueues[0] as any; - secondEventQueue = state.outQueues[1] as any; }); afterEach(() => { @@ -419,11 +433,6 @@ describe('Element Tracking Plugin API', () => { expect(eventQueue).toHaveLength(2); expect(entityOf(eventQueue[0], 'element_statistics')).toHaveLength(1); expect(entityOf(eventQueue[1], 'element_statistics')).toHaveLength(1); - - const statEntities = entityOf(eventQueue[1], 'element_statistics'); - if (Array.isArray(statEntities)) { - expect(statEntities[0]).toEqual({}); - } }); }); }); From b8cc5dff26f30b895318ecfff5e89369141ae2d1 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Thu, 12 Dec 2024 17:30:06 +1100 Subject: [PATCH 26/30] Fix subtree create/destroy detection --- .../src/api.ts | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index dcc5edd89..9e921a4e2 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -224,7 +224,7 @@ export function startElementTracking( trackEvent(Events.ELEMENT_CREATE, config, element, { position: i + 1, matches: elements.length }); - if (intersectionObserver && (expose || obscure)) { + if (intersectionObserver && (expose.when !== Frequency.NEVER || obscure.when !== Frequency.NEVER)) { intersectionObserver.observe(element); } }); @@ -469,14 +469,21 @@ function mutationCallback(mutations: MutationRecord[]): void { } } } else if (record.type === 'childList') { - record.addedNodes.forEach(createFn); + const matches = getMatchingElements(config); + record.addedNodes.forEach((node) => { + matches.filter((m) => node.contains(m)).forEach(createFn); + }); record.removedNodes.forEach((node) => { - if (nodeIsElement(node) && node.matches(config.selector)) { - const state = getState(node); - if (state.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, node); - trackEvent(Events.ELEMENT_DESTROY, config, node); - if (intersectionObserver) intersectionObserver.unobserve(node); - state.state = ElementStatus.DESTROYED; + if (nodeIsElement(node)) { + const removals = node.matches(config.selector) ? [node] : []; + removals.push(...getMatchingElements(config, node)); + removals.forEach((node) => { + const state = getState(node); + if (state.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, node); + trackEvent(Events.ELEMENT_DESTROY, config, node); + if (intersectionObserver) intersectionObserver.unobserve(node); + state.state = ElementStatus.DESTROYED; + }); } }); } From ef814ed305ff0e38c0539ce9e1ec10219cb4b9c7 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Tue, 7 Jan 2025 13:12:11 +1100 Subject: [PATCH 27/30] Update element_statistics for schema feedback --- .../browser-plugin-element-tracking/src/elementsState.ts | 8 ++++---- plugins/browser-plugin-element-tracking/src/schemata.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/src/elementsState.ts b/plugins/browser-plugin-element-tracking/src/elementsState.ts index 5e169df19..6eac999af 100644 --- a/plugins/browser-plugin-element-tracking/src/elementsState.ts +++ b/plugins/browser-plugin-element-tracking/src/elementsState.ts @@ -143,12 +143,12 @@ export function aggregateStats( min_size: minSize.join('x'), current_size: curSize.join('x'), max_size: maxSize.join('x'), - y_depth_percentage: curDepth[1] === 0 ? null : curDepth[0] / curDepth[1], - max_y_depth_percentage: maxDepth[1] === 0 ? null : maxDepth[0] / maxDepth[1], + y_depth_ratio: curDepth[1] === 0 ? null : curDepth[0] / curDepth[1], + max_y_depth_ratio: maxDepth[1] === 0 ? null : maxDepth[0] / maxDepth[1], max_y_depth: maxDepth.join('/'), - element_age_ms: performance.now() - (state.createdTs - performance.timeOrigin), + element_age_ms: Math.floor(performance.now() - (state.createdTs - performance.timeOrigin)), times_in_view: state.views, - total_time_visible_ms: state.elapsedVisibleMs, + total_time_visible_ms: Math.floor(state.elapsedVisibleMs), }, }; } diff --git a/plugins/browser-plugin-element-tracking/src/schemata.ts b/plugins/browser-plugin-element-tracking/src/schemata.ts index 74d974040..84172791b 100644 --- a/plugins/browser-plugin-element-tracking/src/schemata.ts +++ b/plugins/browser-plugin-element-tracking/src/schemata.ts @@ -96,8 +96,8 @@ export type ElementStatisticsEntity = SDJ< min_size: string; current_size: string; max_size: string; - y_depth_percentage: number | null; - max_y_depth_percentage: number | null; + y_depth_ratio: number | null; + max_y_depth_ratio: number | null; max_y_depth: string; element_age_ms: number; times_in_view: number; From 9ee85253792b827062c0915066d6a4ce3615ccec Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Tue, 7 Jan 2025 15:57:06 +1100 Subject: [PATCH 28/30] element: keep track of originating page view ID --- .../browser-plugin-element-tracking/src/api.ts | 18 +++++++++++++----- .../src/data.ts | 1 + .../src/elementsState.ts | 5 +++++ .../src/schemata.ts | 1 + 4 files changed, 20 insertions(+), 5 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index 9e921a4e2..8a5eb04a7 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -89,6 +89,7 @@ const trackedConfigs: Record> = { let LOG: Logger | undefined = undefined; let mutationObserver: MutationObserver | false = false; let intersectionObserver: IntersectionObserver | false = false; +let currentPageViewId: string = ''; /** * Plugin for tracking the addition and removal of elements to a page and the visibility of those elements. @@ -108,10 +109,17 @@ export function SnowplowElementTrackingPlugin({ ignoreNextPageView = true } = {} return { activateBrowserPlugin(tracker) { trackers[tracker.id] = tracker; + currentPageViewId = tracker.getPageViewId(); setupObservers(); }, afterTrack(payload) { if (payload['e'] === 'pv') { + // update originating pageview id + const trackerName = payload['tna']; + if (typeof trackerName === 'string' && trackerName in trackers) { + currentPageViewId = trackers[trackerName].getPageViewId(); + } + // re-set state for `when: pageview` frequency caps Object.values(trackedThisPage).forEach((trackedThisPage) => { // handle book-keeping from above @@ -217,7 +225,7 @@ export function startElementTracking( const elements = getMatchingElements(config); elements.forEach((element, i) => { - const state = getState(element); + const state = getState(element, { originalPageViewId: currentPageViewId }); state.lastPosition = i; state.matches.add(config); @@ -420,7 +428,7 @@ function trackEvent( */ function handleCreate(nowTs: number, config: Configuration, node: Node | Element) { if (nodeIsElement(node) && node.matches(config.selector)) { - const state = getState(node); + const state = getState(node, { originalPageViewId: currentPageViewId }); state.state = ElementStatus.CREATED; state.createdTs = nowTs; state.matches.add(config); @@ -448,7 +456,7 @@ function mutationCallback(mutations: MutationRecord[]): void { if (record.type === 'attributes') { if (nodeIsElement(record.target)) { const element = record.target; - const prevState = getState(element); + const prevState = getState(element, { originalPageViewId: currentPageViewId }); if (prevState.state !== ElementStatus.INITIAL) { if (!element.matches(config.selector)) { @@ -478,7 +486,7 @@ function mutationCallback(mutations: MutationRecord[]): void { const removals = node.matches(config.selector) ? [node] : []; removals.push(...getMatchingElements(config, node)); removals.forEach((node) => { - const state = getState(node); + const state = getState(node, { originalPageViewId: currentPageViewId }); if (state.state === ElementStatus.EXPOSED) trackEvent(Events.ELEMENT_OBSCURE, config, node); trackEvent(Events.ELEMENT_DESTROY, config, node); if (intersectionObserver) intersectionObserver.unobserve(node); @@ -505,7 +513,7 @@ function mutationCallback(mutations: MutationRecord[]): void { function intersectionCallback(entries: IntersectionObserverEntry[], observer: IntersectionObserver): void { entries.forEach((entry) => { let frameRequest: number | undefined = undefined; - const state = getState(entry.target, { lastObservationTs: entry.time }); + const state = getState(entry.target, { lastObservationTs: entry.time, originalPageViewId: currentPageViewId }); configurations.forEach((config) => { if (entry.target.matches(config.selector)) { const siblings = getMatchingElements(config); diff --git a/plugins/browser-plugin-element-tracking/src/data.ts b/plugins/browser-plugin-element-tracking/src/data.ts index 4e53424f2..d370bf94c 100644 --- a/plugins/browser-plugin-element-tracking/src/data.ts +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -239,6 +239,7 @@ export function getElementDetails( doc_position_y: rect.y + window.scrollY, element_index: position, element_matches: matches, + originating_page_view: state.originalPageViewId, attributes: extractSelectorDetails(element, config.selector, config.details), }, }; diff --git a/plugins/browser-plugin-element-tracking/src/elementsState.ts b/plugins/browser-plugin-element-tracking/src/elementsState.ts index 6eac999af..ffbcf41d2 100644 --- a/plugins/browser-plugin-element-tracking/src/elementsState.ts +++ b/plugins/browser-plugin-element-tracking/src/elementsState.ts @@ -30,6 +30,10 @@ type ElementState = { * The above mentioned cumulative visible time. */ elapsedVisibleMs: number; + /** + * The pageview ID when we first observed this element. + */ + originalPageViewId: string; /** * The last position we saw of this element amongst the other matches we saw for this element. */ @@ -76,6 +80,7 @@ export function getState(target: Element, initial: Partial = {}): const state: ElementState = { state: ElementStatus.INITIAL, matches: new Set(), + originalPageViewId: '', createdTs: nowTs + performance.timeOrigin, lastPosition: -1, lastObservationTs: nowTs, diff --git a/plugins/browser-plugin-element-tracking/src/schemata.ts b/plugins/browser-plugin-element-tracking/src/schemata.ts index 84172791b..2da3375a5 100644 --- a/plugins/browser-plugin-element-tracking/src/schemata.ts +++ b/plugins/browser-plugin-element-tracking/src/schemata.ts @@ -74,6 +74,7 @@ export type ElementDetailsEntity = SDJ< doc_position_y: number; element_index?: number; element_matches?: number; + originating_page_view: string; attributes?: AttributeList; } >; From 6aade846aecb33406455fe4a8f29bc53add36091 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Tue, 7 Jan 2025 15:58:27 +1100 Subject: [PATCH 29/30] Use previously known size for obscure/destroy events --- .../browser-plugin-element-tracking/src/api.ts | 11 ++++++++++- .../browser-plugin-element-tracking/src/data.ts | 15 +++++++++++++-- .../src/elementsState.ts | 4 ++++ 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index 8a5eb04a7..92c182be4 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -405,7 +405,16 @@ function trackEvent( context.push(...(config.context(element, config) as Entity[])); if (config.details) { - context.push(getElementDetails(config, element, boundingRect, position, matches)); + context.push( + getElementDetails( + config, + element, + boundingRect, + position, + matches, + schema === Events.ELEMENT_DESTROY || schema === Events.ELEMENT_OBSCURE + ) + ); } if (config.contents.length) { diff --git a/plugins/browser-plugin-element-tracking/src/data.ts b/plugins/browser-plugin-element-tracking/src/data.ts index d370bf94c..99e5062cb 100644 --- a/plugins/browser-plugin-element-tracking/src/data.ts +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -1,5 +1,6 @@ import { SelfDescribingJson } from '@snowplow/tracker-core'; import type { Configuration } from './configuration'; +import { getState } from './elementsState'; import { ElementContentEntity, ElementDetailsEntity, Entities } from './schemata'; import { AttributeList, type DataSelector } from './types'; import { getMatchingElements } from './util'; @@ -214,10 +215,11 @@ export function buildContentTree( /** * Builds an `element` entity. * @param config Configuration describing any additional data that should be included in the entity. - * @param element The elment this entity will describe. + * @param element The element this entity will describe. * @param rect The position/dimension information of the element. * @param position Which match this element is amongst those that match the Configuration's selector. * @param matches The total number, including this one, of elements that matched the Configuration selector. + * @param usePreviousForEmpty Whether to use the previously seen size instead of the current one. Useful if the node no longer exists (e.g. destroyed). Will only apply if the new size is 0. * @returns The Element entity SDJ. */ export function getElementDetails( @@ -225,8 +227,17 @@ export function getElementDetails( element: Element, rect: DOMRect = element.getBoundingClientRect(), position?: number, - matches?: number + matches?: number, + usePreviousForEmpty: boolean = false ): ElementDetailsEntity { + const state = getState(element); + + if (usePreviousForEmpty && state.lastKnownSize && (rect.height === 0 || rect.width === 0)) { + rect = state.lastKnownSize; + } + + state.lastKnownSize = rect; + return { schema: Entities.ELEMENT_DETAILS, data: { diff --git a/plugins/browser-plugin-element-tracking/src/elementsState.ts b/plugins/browser-plugin-element-tracking/src/elementsState.ts index ffbcf41d2..dd1f0ba07 100644 --- a/plugins/browser-plugin-element-tracking/src/elementsState.ts +++ b/plugins/browser-plugin-element-tracking/src/elementsState.ts @@ -38,6 +38,10 @@ type ElementState = { * The last position we saw of this element amongst the other matches we saw for this element. */ lastPosition: number; + /** + * The last non-0 size information available for this element. + */ + lastKnownSize?: DOMRect; /** * The other matches for this element's selector we last saw, of which the element is/was at `lastPosition`-1 position. */ From a758174396dd7a591a4da1d48f444b19051cad29 Mon Sep 17 00:00:00 2001 From: Jethro Nederhof Date: Tue, 7 Jan 2025 16:22:48 +1100 Subject: [PATCH 30/30] Fix createdTs handling --- plugins/browser-plugin-element-tracking/src/api.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/plugins/browser-plugin-element-tracking/src/api.ts b/plugins/browser-plugin-element-tracking/src/api.ts index 92c182be4..748ba95ae 100644 --- a/plugins/browser-plugin-element-tracking/src/api.ts +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -132,6 +132,7 @@ export function SnowplowElementTrackingPlugin({ ignoreNextPageView = true } = {} } }, beforeTrack(payload) { + // attach stat/component entities for configured events const e = payload.getPayload()['e']; let eventName: string; @@ -457,7 +458,7 @@ function handleCreate(nowTs: number, config: Configuration, node: Node | Element * On the other hand, if a CREATE was determined, start observing intersections if that's requested in the configuration. */ function mutationCallback(mutations: MutationRecord[]): void { - const nowTs = performance.now() - performance.timeOrigin; + const nowTs = performance.now() + performance.timeOrigin; mutations.forEach((record) => { configurations.forEach((config) => { const createFn = handleCreate.bind(null, nowTs, config);