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 diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 60f7e3426..22291c27c 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -50,6 +50,10 @@ "name": "@snowplow/browser-plugin-ecommerce", "allowedCategories": [ "trackers" ] }, + { + "name": "@snowplow/browser-plugin-element-tracking", + "allowedCategories": [ "trackers" ] + }, { "name": "@snowplow/browser-plugin-enhanced-consent", "allowedCategories": [ "trackers" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index a3877f873..0e9d56e95 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -457,6 +457,76 @@ importers: specifier: ~4.6.2 version: 4.6.4 + ../../plugins/browser-plugin-element-tracking: + dependencies: + '@snowplow/browser-tracker-core': + specifier: workspace:* + version: link:../../libraries/browser-tracker-core + '@snowplow/tracker-core': + specifier: workspace:* + version: link:../../libraries/tracker-core + tslib: + specifier: ^2.3.1 + version: 2.7.0 + devDependencies: + '@ampproject/rollup-plugin-closure-compiler': + specifier: ~0.27.0 + version: 0.27.0(rollup@2.70.2) + '@rollup/plugin-commonjs': + specifier: ~21.0.2 + version: 21.0.3(rollup@2.70.2) + '@rollup/plugin-node-resolve': + specifier: ~13.1.3 + version: 13.1.3(rollup@2.70.2) + '@types/jest': + specifier: ~27.4.1 + version: 27.4.1 + '@types/jsdom': + specifier: ~16.2.14 + version: 16.2.15 + '@typescript-eslint/eslint-plugin': + specifier: ~5.15.0 + version: 5.15.0(@typescript-eslint/parser@5.15.0(eslint@8.11.0)(typescript@4.6.4))(eslint@8.11.0)(typescript@4.6.4) + '@typescript-eslint/parser': + specifier: ~5.15.0 + version: 5.15.0(eslint@8.11.0)(typescript@4.6.4) + eslint: + specifier: ~8.11.0 + version: 8.11.0 + jest: + specifier: ~27.5.1 + version: 27.5.1(ts-node@10.9.2(@types/node@20.16.3)(typescript@4.6.4)) + jest-environment-jsdom: + specifier: ~27.5.1 + version: 27.5.1 + jest-environment-jsdom-global: + specifier: ~3.0.0 + version: 3.0.0(jest-environment-jsdom@27.5.1) + jest-standard-reporter: + specifier: ~2.0.0 + version: 2.0.0 + rollup: + specifier: ~2.70.1 + version: 2.70.2 + rollup-plugin-cleanup: + specifier: ~3.2.1 + version: 3.2.1(rollup@2.70.2) + rollup-plugin-license: + specifier: ~2.6.1 + version: 2.6.1(rollup@2.70.2) + rollup-plugin-terser: + specifier: ~7.0.2 + version: 7.0.2(rollup@2.70.2) + rollup-plugin-ts: + specifier: ~2.0.5 + version: 2.0.7(@babel/core@7.25.2)(@babel/runtime@7.25.6)(rollup@2.70.2)(typescript@4.6.4) + ts-jest: + specifier: ~27.1.3 + version: 27.1.5(@babel/core@7.25.2)(@types/jest@27.4.1)(babel-jest@27.5.1(@babel/core@7.25.2))(jest@27.5.1(ts-node@10.9.2(@types/node@20.16.3)(typescript@4.6.4)))(typescript@4.6.4) + typescript: + specifier: ~4.6.2 + version: 4.6.4 + ../../plugins/browser-plugin-enhanced-consent: dependencies: '@snowplow/browser-tracker-core': @@ -2101,6 +2171,9 @@ importers: '@snowplow/browser-plugin-client-hints': specifier: workspace:* version: link:../../plugins/browser-plugin-client-hints + '@snowplow/browser-plugin-element-tracking': + specifier: workspace:* + version: link:../../plugins/browser-plugin-element-tracking '@snowplow/browser-plugin-enhanced-consent': specifier: workspace:* version: link:../../plugins/browser-plugin-enhanced-consent diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 523297ad7..f206b3c1e 100644 --- a/common/config/rush/repo-state.json +++ b/common/config/rush/repo-state.json @@ -1,5 +1,5 @@ // DO NOT MODIFY THIS FILE MANUALLY BUT DO COMMIT IT. It is generated and used by Rush. { - "pnpmShrinkwrapHash": "77e8d084b2ff087ff8f5dfa5df436f42dbaa623c", + "pnpmShrinkwrapHash": "713acec7cf6e43954687c4567fd839072e77bc1d", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } 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..e9bc87148 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/README.md @@ -0,0 +1,195 @@ +# 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 SnowplowElementTrackingPlugin and then call `startElementTracking`: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { SnowplowElementTrackingPlugin, startElementTracking } from '@snowplow/browser-plugin-element-tracking'; + +newTracker('sp1', '{{collector_url}}', { + appId: 'my-app-id', + plugins: [ SnowplowElementTrackingPlugin() ], +}); + +startElementTracking({ + elements: [ + {selector: '.newsletter-signup'} + ] +}); +``` + +### Configuration + +Configuration occurs primarily via the `elements` setting passed to `startElementTracking`. + +You can pass a single configuration or an array of multiple configurations. + +Each configuration requires a `selector`, with a CSS selector describing the elements the configuration applies to. +All other configuration is optional. + +You can label each configuration with the `name` property (if not specified, the `selector` is used as the `name`). +The `name` is used in the event payloads and matches the `element_name` of any entities specific to the target element(s). + +The settings control triggering events for: + +- `expose_element`: When a selected element enters the viewport, becoming visible +- `obscure_element`: When a selected element exists the viewport, no longer being visible +- `create_element`: When a selected element is created or exists in the document +- `destroy_element`: When a selected element is removed from or no longer found in the document + +Each of these events can be enabled, disabled, or configured more specifically. +By default, only `expose_element` is enabled. + +Rather than trigger events, configurations can also define the selected elements as "components", which can be listed as a `component_parents` entity for other events; or can have their current state attached to other events (such as page pings) via `element_statistics` entities. + +The plugin manages the following custom entities: + +- `element`: This is shared between all the above events. It contains the `element_name` from the matching configuration, and data about the element that generated the event. This includes the element's dimensions, position (relative to the viewport and document), how many elements matched it's selector (and the index of the element in question, if you selector matches multiple elements). It will also contain custom attributes you can extract from the element via the `details` configuration. +- `component_parents`: For the element generating the event, provides a list of components (defined by the `component` setting) that are ancestors of that element. +- `element_content`: You can also attach details about child elements of the element that matches your selector. E.g. you can select a recommendations widget, and then extract details about the individual recommendations within it. +- `element_statistics`: This entity can be attached to other events and provides a snapshot of what this plugin has observed at that point; it includes the current/smallest/largest dimensions so far, how long the element has existed since it was first observed, its current/maximum scroll depth, its total time in view, and how many times it has been visible in the viewport. + +A detailed example configuration follows: + +```javascript +snowplow('startElementTracking', { + elements: [ + // can be a single or array of many configurations; additive, can be called multiple times to add more configurations, but doesn't dedupe + { + selector: '.oncreate', // required: selector for element, relative to document scope by default + name: 'created div', // logical name: can be shared across multiple configs; defaults to `selector` if not specified; this is used in event payloads and as a key to reference entities + create: true, // track an event when the element is added to the DOM (or when plugin loads if already on page) (default: false) + destroy: true, // track an event when the element is removed from the DOM (or when plugin loads if already on page) (default: false) + expose: true, // track an event when the element intersects with the viewport (default: true) + obscure: true, // track an event when the element scrolls out of the viewport (default: false) + details: [ + // details can be extracted from the element and included in the entity + function (element) { + return { example: 'from a function' }; + }, // use a function that returns an object + { attributes: ['class'], selector: true }, // or declarative options; either as a single object or array elements if you want config re-use; this is less flexible but will be useful to Google Tag Manager where functions may not be able to reference DOM elements + { attributes: ['class'] }, // attributes: get the static/default attributes originally defined on the element when created + { 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; if there's no matching children it will try shadow children + { 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); if no innerText, will try shadow innerText + ], + includeStats: ['page_ping'], // you can include a list of event names here; statistics about elements matching this configuration will be attached as entities to those events; event names don't have to be generated by this plugin so can include built-in events like page_pings or custom events + }, + { selector: 'nav', expose: false, component: true }, // `expose` is true by default so may need disabling; `component` means the name/selector is attached to the component_parents entity list for other events triggered on descendants + { + selector: 'div.toggled', // elements that exist but don't yet match the selector will count as created/destroyed if they later are changed to match it + name: 'mutated div', + create: true, + destroy: true, + expose: false, + obscure: false, + }, + { + selector: '.perpage.toggled', + name: 'perpage mutation', + create: { when: 'pageview' }, // for each type of event you can specify frequency caps for when the event will fire: never, always, once, element, pageview + destroy: { when: 'pageview' }, + /* + the frequency options are "per": + - per never will never track the event, effectively disabling the configuration + - per always will track an event every time it is eligible (e.g. every time on screen when scrolled past) + - per once will only track the event a single time for each configuration for the duration of the plugin instance; this reduces volume since only the first matching element will fire the event + - per element is like once, but for each individually matching element instance + - per pageview is like once, but useful for single-page-applications with long-lasting plugin instances where you may want to track the element on each virtual pageview + */ + expose: false, // `false` is equivalent to `when: never`, and `true` is `when: always` + obscure: false, + }, + { + name: 'recommendations', + selector: '.recommendations', + 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 exposed; 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: { child_text: ['h2'] }, + contents: [ + // content information can be extracted + { + name: 'recommendation-item', // contents can be named too + selector: 'li', // selectors are relative to the parent element + details: { content: { item_name: /.+/ } }, // content item details can be captured too + contents: { name: 'recommendation_image', selector: 'img', details: { attributes: ['alt'] } }, // you can descend contents like a tree + }, + ], + }, + { + name: 'shadow', + selector: 'button.shadow', + shadowSelector: 'shadow-host', // elements within custom components/shadow hosts require their hosts' selectors to be specified + shadowOnly: true, // if the selector could erroneously catch elements outside your shadow hosts, you can restrict it to only match in shadows; by default it will match elements in and out of shadow hosts if they match the selector + }, + ], +}); + +snowplow('getComponentListGenerator', function (componentGenerator, componentGeneratorWithDetail) { + // access a context generator aware of the startElementTracking "components" configuration + // this will attach the component_parents entity to events generated by these plugins that show the component hierarchy + 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('endElementTracking', {elements: ['names']}); // stop tracking all configurations with given `name`s +snowplow('endElementTracking', {elementIds: ['id']}); // to be more specific, each configuration can also have an ID to remove explicitly +snowplow('endElementTracking', {filter: (config) => true}); // decide for yourself if the configuration should be removed; must explicitly return `true` to remove; "truthy" values will not count +snowplow('endElementTracking'); // stop tracking all elements and remove listeners +``` + + +## 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/jest.config.js b/plugins/browser-plugin-element-tracking/jest.config.js new file mode 100644 index 000000000..90f99fb5b --- /dev/null +++ b/plugins/browser-plugin-element-tracking/jest.config.js @@ -0,0 +1,6 @@ +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/package.json b/plugins/browser-plugin-element-tracking/package.json new file mode 100644 index 000000000..01219c7ac --- /dev/null +++ b/plugins/browser-plugin-element-tracking/package.json @@ -0,0 +1,53 @@ +{ + "name": "@snowplow/browser-plugin-element-tracking", + "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", + "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": "~4.1.0" + } +} 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..748ba95ae --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/api.ts @@ -0,0 +1,588 @@ +import { + type BrowserPlugin, + type BrowserTracker, + dispatchToTrackersInCollection, +} from '@snowplow/browser-tracker-core'; +import { type Logger, SelfDescribingJson, buildSelfDescribingEvent } from '@snowplow/tracker-core'; + +import { baseComponentGenerator } from './components'; +import { + ConfigurationState, + checkConfig, + createContextMerger, + type Configuration, + type ContextProvider, + type ElementConfiguration, +} from './configuration'; +import { buildContentTree, evaluateDataSelector, getElementDetails } from './data'; +import { ElementStatus, aggregateStats, getState } from './elementsState'; +import { ComponentsEntity, ElementDetailsEntity, Entity, Events, Event } from './schemata'; +import { Frequency, type OneOrMany } from './types'; +import { getMatchingElements, nodeIsElement, shouldTrackExpose } from './util'; + +/** + * 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; +}; + +/** + * 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; + } + | { + /** + * 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 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; +let currentPageViewId: string = ''; + +/** + * 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'); + }); + } + + 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 + if (trackedThisPage.has('initial')) { + trackedThisPage.delete('initial'); + } else { + trackedThisPage.clear(); + } + }); + } + }, + beforeTrack(payload) { + // attach stat/component entities for configured events + const e = payload.getPayload()['e']; + let eventName: string; + + if (e === 'pv') eventName = 'page_view'; + else if (e === 'pp') eventName = 'page_ping'; + else if (e === 'se') eventName = 'event'; + else if (e === 'tr') eventName = 'transaction'; + else if (e === 'ti') eventName = 'transaction_item'; + else if (e === 'ue') { + const sdjData = payload.getJson(); + for (const sdjWithKey of sdjData) { + if (sdjWithKey.keyIfEncoded === 'ue_px') { + const schema = (sdjWithKey.json.data as SelfDescribingJson).schema; + eventName = schema.split('/')[1]; + } + } + } else return; + + configurations.forEach((config) => { + if (!config.includeStats.includes(eventName) && !config.includeStats.includes('*')) return; + const elements = getMatchingElements(config); + elements.forEach((elem, i, a) => { + payload.addContextEntity(aggregateStats(config.name, elem, i + 1, a.length)); + }); + }); + }, + logger(logger) { + LOG = logger; + }, + }; +} + +function setupObservers() { + 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)); +} + +/** + * Start Element tracking for elements that match the given configuration(s). + * + * 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, + trackers?: Array +): void { + const elementConfigs = Array.isArray(elements) ? elements : [elements]; + + // may have stopped observers via `endElementTracking` + if (elementConfigs.length) setupObservers(); + + elementConfigs.forEach((config) => { + try { + const batchContext = createContextMerger(context, config.context); + const valid = checkConfig(config, batchContext, !!intersectionObserver, !!mutationObserver, LOG, 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); + } + }); + + configurations.forEach((config) => { + const { expose, obscure, state } = config; + if (state === ConfigurationState.INITIAL) { + config.state = ConfigurationState.CONFIGURED; + + const elements = getMatchingElements(config); + + elements.forEach((element, i) => { + const state = getState(element, { originalPageViewId: currentPageViewId }); + + state.lastPosition = i; + state.matches.add(config); + + trackEvent(Events.ELEMENT_CREATE, config, element, { position: i + 1, matches: elements.length }); + + if (intersectionObserver && (expose.when !== Frequency.NEVER || obscure.when !== Frequency.NEVER)) { + intersectionObserver.observe(element); + } + }); + } + }); +} + +/** + * 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]; + + const remaining = configurations.filter(({ id }) => { + const shouldRemove = typeof id === 'string' && idsToRemove.includes(id); + return !shouldRemove; + }); + + configurations.splice(0, configurations.length, ...remaining); + } + + if ('elements' in remove) { + const { elements } = remove; + const elsToRemove = Array.isArray(elements) ? elements : [elements]; + + const remaining = configurations.filter(({ name }) => elsToRemove.includes(name)); + + 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) { + if (intersectionObserver) intersectionObserver.disconnect(); + if (mutationObserver) mutationObserver.disconnect(); + mutationObserver = intersectionObserver = false; + } +} + +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; + +/** + * 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 Callback function to receive the generator callbacks described above asynchronously. + * @returns Array of callbacks described above. + */ +export function getComponentListGenerator( + cb?: (basic: typeof componentGenerator, detailed: typeof detailedComponentGenerator) => void +): [typeof componentGenerator, typeof detailedComponentGenerator] { + if (cb) cb(componentGenerator, detailedComponentGenerator); + return [componentGenerator, detailedComponentGenerator]; +} + +/** + * 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, + element: Element | HTMLElement, + options?: Partial<{ + boundingRect: DOMRect; + position: number; + matches: number; + }> +): void { + const { boundingRect, position, matches } = options ?? {}; + + // core payload + const payload: Event = { + schema, + data: { + element_name: config.name, + }, + }; + + // 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, + }; + + if (conditions[schema]) { + if (!evaluateDataSelector(element, config.selector, conditions[schema]!).length) return; + } + + // 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, + }; + + 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 | SelfDescribingJson)[] = []; + + context.push(...(config.context(element, config) as Entity[])); + + if (config.details) { + context.push( + getElementDetails( + config, + element, + boundingRect, + position, + matches, + schema === Events.ELEMENT_DESTROY || schema === Events.ELEMENT_OBSCURE + ) + ); + } + + 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) => { + const event = buildSelfDescribingEvent({ event: payload }); + tracker.core.track(event, context); + }); +} + +/** + * 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)) { + const state = getState(node, { originalPageViewId: currentPageViewId }); + state.state = ElementStatus.CREATED; + state.createdTs = nowTs; + state.matches.add(config); + trackEvent(Events.ELEMENT_CREATE, config, node); + if (config.expose.when !== Frequency.NEVER && intersectionObserver) intersectionObserver.observe(node); + } +} + +/** + * 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) => { + configurations.forEach((config) => { + const createFn = handleCreate.bind(null, nowTs, config); + + if (record.type === 'attributes') { + if (nodeIsElement(record.target)) { + const element = record.target; + const prevState = getState(element, { originalPageViewId: currentPageViewId }); + + if (prevState.state !== ElementStatus.INITIAL) { + 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); + prevState.state = ElementStatus.DESTROYED; + } + } else { + if (!prevState.matches.has(config)) { + createFn(element); + } + } + } else { + createFn(element); + } + } + } else if (record.type === 'childList') { + const matches = getMatchingElements(config); + record.addedNodes.forEach((node) => { + matches.filter((m) => node.contains(m)).forEach(createFn); + }); + record.removedNodes.forEach((node) => { + if (nodeIsElement(node)) { + const removals = node.matches(config.selector) ? [node] : []; + removals.push(...getMatchingElements(config, node)); + removals.forEach((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); + state.state = ElementStatus.DESTROYED; + }); + } + }); + } + }); + }); +} + +/** + * 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) => { + let frameRequest: number | undefined = undefined; + const state = getState(entry.target, { lastObservationTs: entry.time, originalPageViewId: currentPageViewId }); + configurations.forEach((config) => { + if (entry.target.matches(config.selector)) { + const siblings = getMatchingElements(config); + const position = siblings.findIndex((el) => el.isSameNode(entry.target)) + 1; + + if (entry.isIntersecting) { + if (state.state !== ElementStatus.EXPOSED && state.state !== ElementStatus.PENDING) { + Object.assign(state, { + state: ElementStatus.PENDING, + lastObservationTs: entry.time, + views: state.views + 1, + }); + } + + if (state.state === ElementStatus.PENDING) { + // check configured criteria, if any + if (shouldTrackExpose(config, entry)) { + // check time criteria + if (config.expose.minTimeMillis <= state.elapsedVisibleMs) { + state.state = ElementStatus.EXPOSED; + trackEvent(Events.ELEMENT_EXPOSE, config, entry.target, { + boundingRect: entry.boundingClientRect, + position, + matches: siblings.length, + }); + } + } + } + + if (state.state === ElementStatus.PENDING || state.state === ElementStatus.EXPOSED) { + const elapsedVisibleMs = state.elapsedVisibleMs + (entry.time - state.lastObservationTs); + Object.assign(state, { + lastObservationTs: entry.time, + elapsedVisibleMs, + }); + + // check visibility time next frame + if (!frameRequest) { + frameRequest = requestAnimationFrame(() => { + observer.unobserve(entry.target); // observe is no-op for already observed elements + observer.observe(entry.target); // for non-observed elements, it immediately generates an entry of current state + }); + } + } + } else { + if (state.state === ElementStatus.EXPOSED) { + trackEvent(Events.ELEMENT_OBSCURE, config, entry.target, { + boundingRect: entry.boundingClientRect, + position, + matches: siblings.length, + }); + } + + Object.assign(state, { + state: state.state === ElementStatus.DESTROYED ? ElementStatus.DESTROYED : ElementStatus.OBSCURED, + lastObservationTs: entry.time, + }); + } + } + }); + }); +} 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..9c8e9fdb6 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/components.ts @@ -0,0 +1,50 @@ +import type { Configuration } from './configuration'; +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[], + ...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 new file mode 100644 index 000000000..d15db3f3e --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/configuration.ts @@ -0,0 +1,310 @@ +import type { Logger, SelfDescribingJson } from '@snowplow/tracker-core'; + +import { isDataSelector } from './data'; +import { type DataSelector, Frequency, type OneOrMany, type RequiredExcept } from './types'; + +export enum ConfigurationState { + INITIAL, + 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[]); + +/** + * 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; + /** + * Types of events that statistics for the element should be included on, if any. Should be the `event_name` value for events that should have the entity attached. E.g. `page_view`, `page_ping`, `expose_element`, `my_custom_event`. + */ + includeStats?: 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' | 'includeStats' | 'contents' +> & { + trackers?: string[]; + create: BaseOptions; + destroy: BaseOptions; + expose: RequiredExcept; + obscure: BaseOptions; + state: ConfigurationState; + details: DataSelector[]; + includeStats: string[]; + contents: Configuration[]; + context: Extract; +}; + +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(batchContext?: ContextProvider, configContext?: ContextProvider): ContextProvider { + return function contextMerger(element, config) { + const result: SelfDescribingJson[] = []; + + for (const contextSrc of [batchContext, configContext]) { + 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, + intersectionPossible: boolean, + mutationPossible: boolean, + logger?: Logger, + trackers?: string[] +): 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}`); + + // these will throw if selectors invalid + document.querySelector(selector); + if (shadowSelector) document.querySelector(shadowSelector); + + // 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') { + const { when = 'always', condition } = input; + + if (condition && !isDataSelector(condition)) throw new Error('Invalid data selector provided for condition'); + + if (when.toUpperCase() in Frequency) { + return { + when: when.toLowerCase() as Frequency, + condition, + }; + } else { + throw new Error(`Unknown tracking frequency: ${when}`); + } + } + return DEFAULT_FREQUENCY_OPTIONS; + }); + + if ((validCreate.when !== Frequency.NEVER || validDestroy.when !== Frequency.NEVER) && !mutationPossible) + logger?.warn('MutationObserver API unavailable but required for events in configuration:', config); + + let validExpose: RequiredExcept | null = null; + + // expose has custom options and is more complex + 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 = { + when: when.toLowerCase() 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, + }; + } + + if ((validExpose.when !== Frequency.NEVER || validObscure.when !== Frequency.NEVER) && !intersectionPossible) + logger?.warn('IntersectionObserver API unavailable but required for events in configuration:', config); + + // normalize to arrays (scalars allowed in input for convenience) + let { details = [], contents = [], includeStats = [] } = config; + + if (!Array.isArray(details)) details = details == null ? [] : [details]; + if (!Array.isArray(contents)) contents = contents == null ? [] : [contents]; + if (!Array.isArray(includeStats)) includeStats = includeStats == null ? [] : [includeStats]; + + if (details.length !== details.filter(isDataSelector).length) + throw new Error('Invalid DataSelector given for details'); + + return { + name, + selector, + id, + shadowSelector, + shadowOnly, + create: validCreate, + destroy: validDestroy, + expose: validExpose, + obscure: validObscure, + component: !!component, + details, + includeStats, + contents: contents.map((inner) => + checkConfig(inner, inner.context ?? emptyProvider, intersectionPossible, mutationPossible, logger, 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 new file mode 100644 index 000000000..99e5062cb --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/data.ts @@ -0,0 +1,257 @@ +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'; + +/** + * 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', '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); + 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 Flattened list of results found processing the element. + */ +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, + 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 = '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) || element.shadowRoot?.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]) => { + if (!(pattern instanceof RegExp)) + try { + pattern = new RegExp(pattern); + } catch (e) { + return; + } + + const innerText = element.textContent || element.shadowRoot?.textContent; + if (innerText) { + try { + const match = pattern.exec(innerText); + 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 }); + } + + 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 && (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 | SelfDescribingJson)[] { + const context: (ElementContentEntity | SelfDescribingJson)[] = []; + + if (element && config.contents.length) { + config.contents.forEach((contentConfig) => { + const contents = getMatchingElements(contentConfig, element); + + 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(...contentConfig.context(contentElement, contentConfig)); + context.push(...buildContentTree(contentConfig, contentElement, i + 1)); + }); + }); + } + + return context; +} + +/** + * Builds an `element` entity. + * @param config Configuration describing any additional data that should be included in the entity. + * @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( + config: Configuration, + element: Element, + rect: DOMRect = element.getBoundingClientRect(), + position?: 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: { + element_name: config.name, + width: rect.width, + height: rect.height, + position_x: rect.x, + position_y: rect.y, + doc_position_x: rect.x + window.scrollX, + 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 new file mode 100644 index 000000000..dd1f0ba07 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/elementsState.ts @@ -0,0 +1,163 @@ +import type { Configuration } from './configuration'; +import { Entities, type ElementStatisticsEntity } from './schemata'; + +export enum ElementStatus { + INITIAL, + CREATED, + DESTROYED, + EXPOSED, + PENDING, + 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 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. + */ + 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. + */ + matches: Set; + /** + * Smallest size dimensions seen so far for this element. + */ + minSize?: [number, number]; + /** + * Largest size dimensions seen so far for this element. + */ + maxSize?: [number, number]; + /** + * Largest vertical depth seen so far for this element, and the height the element was at that time. + */ + maxDepth?: [number, number]; + /** + * Number of times this element has entered viewport so far. + */ + views: number; +}; + +/** + * Bank of per-element state that needs to be stored. + */ +const elementsState = + typeof WeakMap !== 'undefined' ? new WeakMap() : new Map(); + +/** + * Obtain element state from `elementState`, creating with sane defaults if element is unknown. + * @param target Element to obtain state for. + * @param initial Initial state to include in the state if created. + * @returns State for the target element. + */ +export function getState(target: Element, initial: Partial = {}): ElementState { + if (elementsState.has(target)) { + return elementsState.get(target)!; + } else { + const nowTs = performance.now(); + const state: ElementState = { + state: ElementStatus.INITIAL, + matches: new Set(), + originalPageViewId: '', + createdTs: nowTs + performance.timeOrigin, + lastPosition: -1, + lastObservationTs: nowTs, + elapsedVisibleMs: 0, + views: 0, + ...initial, + }; + elementsState.set(target, state); + return state; + } +} + +const stateLabels: Record = { + [ElementStatus.CREATED]: 'exists', + [ElementStatus.DESTROYED]: 'destroyed', + [ElementStatus.EXPOSED]: 'on screen', + [ElementStatus.INITIAL]: 'unknown', + [ElementStatus.OBSCURED]: 'off screen', + [ElementStatus.PENDING]: 'awaiting time criteria', +}; + +const compareSize = (a: [number, number], b?: [number, number]) => { + if (b === undefined) return 0; + const asize = a[0] * a[1]; + const bsize = b[0] * b[1]; + return asize - bsize; +}; + +export function aggregateStats( + element_name: string, + target: Element, + index: number, + matches: number +): ElementStatisticsEntity { + const state = getState(target); + const stateLabel = stateLabels[state.state]; + + const rect = target.getBoundingClientRect(); + const curSize: [number, number] = [rect.width, rect.height]; + const minSize = compareSize(curSize, state.minSize) <= 0 ? curSize : state.minSize!; + const maxSize = compareSize(curSize, state.maxSize) < 0 ? state.maxSize! : curSize; + + const vpBottom = window.visualViewport + ? window.visualViewport.height + window.visualViewport.pageTop + : window.innerHeight + window.scrollY; + const curDepth: [number, number] = [Math.min(vpBottom, rect.top + rect.height) - rect.top, rect.height]; + const maxDepth = + state.maxDepth === undefined || state.maxDepth[1] === 0 + ? curDepth + : curDepth[1] === 0 + ? state.maxDepth + : curDepth[0] / curDepth[1] > state.maxDepth[0] / state.maxDepth[1] + ? curDepth + : state.maxDepth; + + Object.assign(state, { minSize, maxSize, maxDepth }); + + return { + schema: Entities.ELEMENT_STATISTICS, + data: { + element_name, + element_index: index, + element_matches: matches, + current_state: stateLabel, + min_size: minSize.join('x'), + current_size: curSize.join('x'), + max_size: maxSize.join('x'), + 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: Math.floor(performance.now() - (state.createdTs - performance.timeOrigin)), + times_in_view: state.views, + total_time_visible_ms: Math.floor(state.elapsedVisibleMs), + }, + }; +} 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..2da3375a5 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/schemata.ts @@ -0,0 +1,107 @@ +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_index: number; + element_name: string; + element_index: number; + attributes?: AttributeList; + } +>; + +export type ElementDetailsEntity = SDJ< + Entities.ELEMENT_DETAILS, + { + element_name: string; + height: number; + width: number; + position_x: number; + position_y: number; + doc_position_x: number; + doc_position_y: number; + element_index?: number; + element_matches?: number; + originating_page_view: string; + attributes?: AttributeList; + } +>; + +export type ComponentsEntity = SDJ< + Entities.COMPONENT_PARENTS, + { + element_name?: string; + component_list: string[]; + } +>; + +export type ElementStatisticsEntity = SDJ< + Entities.ELEMENT_STATISTICS, + { + element_name: string; + element_index: number; + element_matches: number; + current_state: string; + min_size: string; + current_size: string; + max_size: string; + y_depth_ratio: number | null; + max_y_depth_ratio: number | null; + max_y_depth: string; + element_age_ms: number; + times_in_view: number; + total_time_visible_ms: number; + } +>; 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..1f046bfe3 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/types.ts @@ -0,0 +1,76 @@ +/** + * Like `Required`, but any keys in `E` are optional instead of required. + */ +export type RequiredExcept = { + [P in Exclude]-?: Exclude; +} & { + [P in E]?: T[P]; +}; + +/** + * A thing, or an array of thing. Just a neater interface for the user writing configuration. + */ +export type OneOrMany = T | T[]; + +/** + * An attribute list details the results of running DataSelectors on an element. + * + * It's a list of key-value pairs along with a label for the source DataSelector to remove ambiguity. + * The list of objects is chosen for BigQuery compat, as it doesn't really support arbitrary "map" types. + */ +export type AttributeList = { + source: string; + attribute: string; + value: string; +}[]; + +/** + * 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[] } + | { properties: string[] } + | { dataset: string[] } + | { selector: boolean } + | { content: Record } + | { child_text: Record } + | { match: Record boolean)> }; + +/** + * 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', +} 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..030fc3d46 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/src/util.ts @@ -0,0 +1,85 @@ +import { Configuration } from './configuration'; +import { Frequency } from './types'; + +/** + * 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') { + 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 }; +} + +/** + * 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)); + + if (shadowSelector) { + Array.from(target.querySelectorAll(shadowSelector), (host) => { + 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)); + } + }); + } + + 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; +} + +/** + * 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. + */ +export function shouldTrackExpose(config: Configuration, entry: IntersectionObserverEntry): boolean { + if (config.expose.when === Frequency.NEVER) return false; + if (!entry.isIntersecting) return false; + + const { boundaryPixels, minPercentage, minSize } = config.expose; + + const { boundTop, boundRight, boundBottom, boundLeft } = defineBoundaries(boundaryPixels); + + const { boundingClientRect } = entry; + if (boundingClientRect.height * boundingClientRect.width < minSize) return false; + + 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 (minPercentage > intersectionArea / boundingArea) return false; + + return true; +} 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..f1c10359e --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/api.test.ts @@ -0,0 +1,551 @@ +import { addTracker, BrowserTracker, SharedState } from '@snowplow/browser-tracker-core'; +import type { Logger } from '@snowplow/tracker-core'; + +import { SnowplowElementTrackingPlugin, endElementTracking, startElementTracking } from '../src'; +import { AttributeList } from '../src/types'; + +/** + * Note: jsdom does not support IntersectionObserver so unit tests can only cover create/destroy events. + * + * https://github.com/jsdom/jsdom/issues/2032 + */ + +/** + * The plugin performs asynchronously when possible for performance. + * To actually see generated events, we need to check for them in a new task. + * This fn handles boilerplate for that. + * @param cb Callback making actual assertions + * @returns Promise resolving after callback succeeds after executing in new task. Rejects if `cb` throws to avoid timeouts. + */ +const inNewTask = (cb: () => void) => + new Promise((resolve, reject) => { + expect.hasAssertions(); + setTimeout(() => { + try { + resolve(cb()); + } catch (e) { + reject(e); + } + }, 50); + }); + +const eventSDJ = ( + field: string, + evt: { evt: Record }, + schema: string +): Record | Record[] | undefined => { + try { + const payloadString = evt.evt[field] as string; + const payload = JSON.parse(payloadString); + + if (/^ue_p[rx]$/.test(field)) { + if (payload.data.schema.split('/').includes(schema)) return payload.data; + } else if (/^c[ox]$/.test(field)) { + return payload.data.filter((e: any) => e.schema.split('/').includes(schema)).map((e: any) => e.data); + } + } catch (e) {} + return; +}; + +const unstructOf = eventSDJ.bind(null, 'ue_pr'); +const entityOf = eventSDJ.bind(null, 'co'); + +const FAKE_CONTEXT_SCHEMA = 'iglu:com.example/custom_entity/jsonschema/1-0-0'; + +describe('Element Tracking Plugin API', () => { + const eventQueue: { evt: Record }[] = []; + const secondEventQueue: { evt: Record }[] = []; + let warnLog: jest.SpyInstance>; + let tracker: BrowserTracker; + + beforeAll(() => { + const state = new SharedState(); + const plugin = SnowplowElementTrackingPlugin({ ignoreNextPageView: false }); + + const activateBrowserPlugin = jest.spyOn(plugin, 'activateBrowserPlugin'); + const logger = jest.spyOn(plugin, 'logger'); + + tracker = addTracker('test', 'test', 'js-test', '', state, { + stateStorageStrategy: 'cookie', + encodeBase64: false, + plugins: [ + plugin, + { + beforeTrack: (pb) => { + eventQueue.push({ evt: pb.build() }); + }, + }, + ], + customFetch: async () => new Response(null, { status: 200 }), + })!; + + expect(activateBrowserPlugin).toHaveBeenCalled(); + expect(logger).toHaveBeenCalledTimes(1); + + addTracker('test2', 'test2', 'js-test', '', state, { + stateStorageStrategy: 'cookie', + encodeBase64: false, + 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'); + }); + + afterEach(() => { + endElementTracking(); + document.body.replaceChildren(); + warnLog.mockReset(); + eventQueue.length = 0; + secondEventQueue.length = 0; + }); + + describe('startElementTracking', () => { + it('warns for unavailable observer', () => { + expect(typeof IntersectionObserver).toBe('undefined'); + + startElementTracking({ elements: { selector: 'div', expose: true } }); + + return inNewTask(() => { + expect(warnLog).toHaveBeenCalled(); + expect(eventQueue).toHaveLength(0); + }); + }); + + it('silent when not requesting unavailable observer', () => { + expect(typeof IntersectionObserver).toBe('undefined'); + expect(typeof MutationObserver).toBe('function'); + + startElementTracking({ elements: { selector: 'div', expose: false, create: true } }); + + return inNewTask(() => { + expect(warnLog).not.toHaveBeenCalled(); + expect(eventQueue).toHaveLength(0); + }); + }); + + it('tracks element creation', () => { + startElementTracking({ elements: { selector: '.newelement', expose: false, create: true } }); + + const div = document.createElement('div'); + div.classList.add('newelement'); + document.body.appendChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + expect(entityOf(eventQueue[0], 'element')).toHaveLength(1); + }); + }); + + it('tracks element removal', () => { + const div = document.createElement('div'); + div.classList.add('existing'); + document.body.appendChild(div); + + startElementTracking({ elements: [{ selector: '.existing', expose: false, destroy: true }] }); + + document.body.removeChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + expect(unstructOf(eventQueue[0], 'destroy_element')).toBeDefined(); + expect(entityOf(eventQueue[0], 'element')).toHaveLength(1); + }); + }); + + it('tracks element mutation', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + startElementTracking({ elements: [{ selector: '.mutated', expose: false, create: true, destroy: true }] }); + + // we split these changes into microtasks or else the double toggle can't actually be observed + return Promise.resolve() + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => + inNewTask(() => { + expect(eventQueue).toHaveLength(3); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + expect(unstructOf(eventQueue[1], 'destroy_element')).toBeDefined(); + expect(unstructOf(eventQueue[2], 'create_element')).toBeDefined(); + }) + ); + }); + + it('includes advanced details', () => { + startElementTracking({ + elements: [ + { + selector: 'body', + component: true, + }, + { + selector: '.advanced', + expose: false, + create: { + when: 'always', + }, + details: { properties: ['className'] }, + contents: { selector: 'h1', details: { content: { heading_text: '.+' } } }, + context: () => [{ schema: FAKE_CONTEXT_SCHEMA, data: { from: 'config' } }], + }, + ], + context: [{ schema: FAKE_CONTEXT_SCHEMA, data: { from: 'batch' } }], + }); + + const div = document.createElement('div'); + div.classList.add('advanced'); + const heading = document.createElement('h1'); + heading.replaceChildren('Heading'); + div.replaceChildren(heading); + document.body.appendChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + + expect(entityOf(eventQueue[0], 'element')).toHaveLength(2); // .advanced and body component + const elementEntities = entityOf(eventQueue[0], 'element'); + const elementEntity = Array.isArray(elementEntities) ? elementEntities[0] : (undefined as never); + const attributeList = elementEntity.attributes as AttributeList; + expect(attributeList).toHaveLength(1); + expect(attributeList).toEqual([ + { + source: 'properties', + attribute: 'className', + value: 'advanced', + }, + ]); + + expect(entityOf(eventQueue[0], 'component_parents')).toEqual([ + { + element_name: '.advanced', + component_list: ['body'], + }, + ]); + expect(entityOf(eventQueue[0], 'element_content')).toEqual([ + { + element_name: 'h1', + parent_name: '.advanced', + parent_position: 1, + position: 1, + attributes: [ + { + source: 'content', + attribute: 'heading_text', + value: 'Heading', + }, + ], + }, + ]); + expect(entityOf(eventQueue[0], 'custom_entity')).toEqual([{ from: 'batch' }, { from: 'config' }]); + }); + }); + + it('frequency: once', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const div2 = document.createElement('div'); + document.body.appendChild(div2); + + startElementTracking({ elements: [{ selector: '.mutated', expose: false, create: { when: 'once' } }] }); + + // we split these changes into microtasks or else the double toggle can't actually be observed + return Promise.resolve() + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); // this create should be skipped + }) + .then(() => { + div2.classList.toggle('mutated'); // second div should skip too + }) + .then(() => + inNewTask(() => { + expect(eventQueue).toHaveLength(1); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + }) + ); + }); + + it('frequency: element', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + const div2 = document.createElement('div'); + document.body.appendChild(div2); + + startElementTracking({ + elements: [{ selector: '.mutated', expose: false, create: { when: 'element' }, destroy: true }], + }); + + // we split these changes into microtasks or else the double toggle can't actually be observed + return Promise.resolve() + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); // this create should be skipped + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div2.classList.toggle('mutated'); // but not for div2 + }) + .then(() => + inNewTask(() => { + expect(eventQueue).toHaveLength(4); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + expect(unstructOf(eventQueue[1], 'destroy_element')).toBeDefined(); + expect(unstructOf(eventQueue[2], 'destroy_element')).toBeDefined(); + expect(unstructOf(eventQueue[3], 'create_element')).toBeDefined(); + }) + ); + }); + + it('frequency: pageview', () => { + const div = document.createElement('div'); + document.body.appendChild(div); + + startElementTracking({ + elements: [{ selector: '.mutated', expose: false, create: { when: 'pageview' }, destroy: true }], + }); + + // we split these changes into microtasks or else the double toggle can't actually be observed + return Promise.resolve() + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => { + div.classList.toggle('mutated'); // this create should be skipped + }) + .then(() => { + div.classList.toggle('mutated'); + }) + .then(() => + inNewTask(() => { + tracker.trackPageView(); // new task or the order is weird + }) + ) + .then(() => { + div.classList.toggle('mutated'); // new pv, don't skip this one + }) + .then(() => + inNewTask(() => { + expect(eventQueue).toHaveLength(5); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + expect(unstructOf(eventQueue[1], 'destroy_element')).toBeDefined(); + expect(unstructOf(eventQueue[2], 'destroy_element')).toBeDefined(); + expect(eventQueue[3].evt.e).toBe('pv'); + expect(unstructOf(eventQueue[4], 'create_element')).toBeDefined(); + }) + ); + }); + + it('evaluates conditions', () => { + startElementTracking({ + elements: { + selector: 'div', + expose: false, + create: { + when: 'always', + condition: { dataset: ['fake'] }, + }, + }, + }); + + const div = document.createElement('div'); + document.body.appendChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(0); + }); + }); + + it('dedupes by id', () => { + startElementTracking({ + elements: { + id: 'test', + selector: 'span', + expose: false, + create: true, + }, + }); + + startElementTracking({ + elements: { + id: 'test', + selector: 'div', + expose: false, + create: true, + }, + }); + + for (const nodeName of ['div', 'span']) { + const element = document.createElement(nodeName); + document.body.appendChild(element); + } + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + }); + }); + + it('adds element stats', () => { + startElementTracking({ + elements: { selector: '.newelement', expose: false, create: false, includeStats: 'page_view' }, + }); + + const div = document.createElement('div'); + div.classList.add('newelement'); + document.body.appendChild(div); + + tracker.trackPageView(); + tracker.trackPageView(); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(2); + expect(entityOf(eventQueue[0], 'element_statistics')).toHaveLength(1); + expect(entityOf(eventQueue[1], 'element_statistics')).toHaveLength(1); + }); + }); + }); + + describe('multiple tracker', () => { + it('works with multiple trackers', () => { + startElementTracking({ elements: { selector: '.newelement', expose: false, create: true } }); + + const div = document.createElement('div'); + div.classList.add('newelement'); + document.body.appendChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + expect(secondEventQueue).toHaveLength(1); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + expect(unstructOf(secondEventQueue[0], 'create_element')).toBeDefined(); + expect(entityOf(eventQueue[0], 'element')).toHaveLength(1); + expect(entityOf(secondEventQueue[0], 'element')).toHaveLength(1); + }); + }); + + it('works with selective trackers', () => { + startElementTracking({ elements: { selector: '.newelement', expose: false, create: true } }, ['test']); + + const div = document.createElement('div'); + div.classList.add('newelement'); + document.body.appendChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + expect(secondEventQueue).toHaveLength(0); + expect(unstructOf(eventQueue[0], 'create_element')).toBeDefined(); + expect(entityOf(eventQueue[0], 'element')).toHaveLength(1); + }); + }); + }); + + describe('endElementTracking', () => { + it('untracks by id', () => { + startElementTracking({ + elements: { + id: 'test', + selector: 'div', + expose: false, + create: true, + }, + }); + + endElementTracking({ elementIds: ['test'] }); + + const div = document.createElement('div'); + document.body.appendChild(div); + + return inNewTask(() => { + expect(eventQueue).toHaveLength(0); + }); + }); + + it('untracks by name', () => { + startElementTracking({ + elements: [ + { + selector: 'div', + expose: false, + create: true, + }, + { + selector: 'span', + expose: false, + create: true, + }, + ], + }); + + endElementTracking({ elements: ['span'] }); + + for (const nodeName of ['div', 'span']) { + const element = document.createElement(nodeName); + document.body.appendChild(element); + } + + return inNewTask(() => { + expect(eventQueue).toHaveLength(1); + }); + }); + + it('untracks by filter', () => { + startElementTracking({ + elements: [ + { + selector: 'div', + expose: false, + create: true, + }, + { + selector: 'span', + expose: false, + create: true, + }, + ], + }); + + endElementTracking({ filter: (config) => config.create.when === 'always' }); + + for (const nodeName of ['div', 'span']) { + const element = document.createElement(nodeName); + document.body.appendChild(element); + } + + return inNewTask(() => { + expect(eventQueue).toHaveLength(0); + }); + }); + }); +}); 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..6dabfb996 --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/components.test.ts @@ -0,0 +1,43 @@ +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: [], + includeStats: [], + }; + + 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..b1818d1de --- /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, true, true); + 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, true, true); + } 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..cb0b2552a --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/data.test.ts @@ -0,0 +1,139 @@ +import { evaluateDataSelector, extractSelectorDetails, isDataSelector } from '../src/data'; +import type { DataSelector } from '../src/types'; + +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/util.test.ts b/plugins/browser-plugin-element-tracking/test/util.test.ts new file mode 100644 index 000000000..6325ab74e --- /dev/null +++ b/plugins/browser-plugin-element-tracking/test/util.test.ts @@ -0,0 +1,246 @@ +import { defineBoundaries, getMatchingElements, nodeIsElement, shouldTrackExpose } from '../src/util'; +import type { 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: [], + includeStats: [], +}; + +const TEMPLATE_DOMRECT: DOMRectReadOnly = { + bottom: 0, + top: 0, + left: 0, + right: 0, + height: 0, + width: 0, + x: 0, + y: 0, + toJSON() {}, +}; + +const TEMPLATE_INTERSECTION: IntersectionObserverEntry = { + time: 0, + isIntersecting: true, + target: document.body, + rootBounds: null, + boundingClientRect: TEMPLATE_DOMRECT, + intersectionRatio: 0, + intersectionRect: TEMPLATE_DOMRECT, +}; + +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])); + }); + }); + }); + + describe('shouldTrackExpose', () => { + it.each([ + ['frequency: never', { when: 'never' as const }, {}, false], + ['not intersecting', { when: 'always' as const }, { isIntersecting: false }, false], + ['intersecting', { when: 'always' as const }, { isIntersecting: true }, true], + ['zero-size', { when: 'always' as const, minSize: 1 }, { isIntersecting: true }, false], + [ + 'partial view', + { when: 'always' as const, minPercentage: 1 }, + { + intersectionRatio: (100 * 100) / (200 * 200), + intersectionRect: { ...TEMPLATE_DOMRECT, width: 100, height: 100 }, + boundingClientRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + }, + false, + ], + [ + 'sufficent view', + { when: 'always' as const, minPercentage: (200 * 160) / (200 * 200) }, + { + intersectionRatio: (200 * 160) / (200 * 200), + intersectionRect: { ...TEMPLATE_DOMRECT, width: 200, height: 160 }, + boundingClientRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + }, + true, + ], + [ + 'insufficent view', + { when: 'always' as const, minPercentage: (200 * 160) / (200 * 200) }, + { + intersectionRatio: (200 * 159) / (200 * 200), + intersectionRect: { ...TEMPLATE_DOMRECT, width: 200, height: 159 }, + boundingClientRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + }, + false, + ], + [ + 'shrunken element', + { when: 'always' as const, minPercentage: (200 * 160) / (200 * 200), boundaryPixels: -100 }, + { + intersectionRatio: (200 * 159) / (200 * 200), + intersectionRect: { ...TEMPLATE_DOMRECT, width: 200, height: 159 }, + boundingClientRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + }, + true, + ], + [ + 'enlarged element - possible', + { when: 'always' as const, minPercentage: 0.1, boundaryPixels: 100 }, + { + intersectionRatio: (200 * 200) / (200 * 200), + intersectionRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + boundingClientRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + }, + true, + ], + [ + 'enlarged element - impossible', + { when: 'always' as const, minPercentage: 1, boundaryPixels: 100 }, + { + intersectionRatio: (200 * 200) / (200 * 200), + intersectionRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + boundingClientRect: { ...TEMPLATE_DOMRECT, width: 200, height: 200 }, + }, + false, + ], + ])( + '%s', + ( + _: string, + expose: Partial, + entry: Partial, + shouldPass: boolean + ) => { + expect( + shouldTrackExpose( + { + ...TEMPLATE_CONFIG, + expose: { ...TEMPLATE_CONFIG.expose, ...expose }, + }, + { + ...TEMPLATE_INTERSECTION, + ...entry, + } + ) + ).toBe(shouldPass); + } + ); + }); +}); 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", diff --git a/trackers/javascript-tracker/package.json b/trackers/javascript-tracker/package.json index e25caa03f..31edda883 100644 --- a/trackers/javascript-tracker/package.json +++ b/trackers/javascript-tracker/package.json @@ -41,6 +41,7 @@ "dependencies": { "@snowplow/browser-plugin-ad-tracking": "workspace:*", "@snowplow/browser-plugin-client-hints": "workspace:*", + "@snowplow/browser-plugin-element-tracking": "workspace:*", "@snowplow/browser-plugin-enhanced-ecommerce": "workspace:*", "@snowplow/browser-plugin-error-tracking": "workspace:*", "@snowplow/browser-plugin-form-tracking": "workspace:*", diff --git a/trackers/javascript-tracker/src/features.ts b/trackers/javascript-tracker/src/features.ts index a667a772e..940a416a4 100644 --- a/trackers/javascript-tracker/src/features.ts +++ b/trackers/javascript-tracker/src/features.ts @@ -24,6 +24,7 @@ import * as ButtonClickTracking from '@snowplow/browser-plugin-button-click-trac import * as EventSpecifications from '@snowplow/browser-plugin-event-specifications'; import * as PerformanceNavigationTiming from '@snowplow/browser-plugin-performance-navigation-timing'; import * as WebVitals from '@snowplow/browser-plugin-web-vitals'; +import * as ElementTracking from '@snowplow/browser-plugin-element-tracking'; /** * Calculates the required plugins to intialise per tracker @@ -153,5 +154,10 @@ export function Plugins(configuration: JavaScriptTrackerConfiguration) { activatedPlugins.push([WebVitalsPlugin(typeof webVitals === 'object' ? webVitals : undefined), apiMethods]); } + if (plugins.elementTracking) { + const { SnowplowElementTrackingPlugin, ...apiMethods } = ElementTracking; + activatedPlugins.push([SnowplowElementTrackingPlugin(), apiMethods]); + } + return activatedPlugins; } diff --git a/trackers/javascript-tracker/tracker.config.ts b/trackers/javascript-tracker/tracker.config.ts index db9e10ab6..144bf7ecf 100644 --- a/trackers/javascript-tracker/tracker.config.ts +++ b/trackers/javascript-tracker/tracker.config.ts @@ -22,6 +22,7 @@ export const privacySandbox = false; export const eventSpecifications = false; export const geolocation = false; export const timezone = false; +export const elementTracking = false; /* Deprecated */ export const enhancedEcommerce = false; diff --git a/trackers/javascript-tracker/tracker.lite.config.ts b/trackers/javascript-tracker/tracker.lite.config.ts index 0bac546e3..4bc2a4227 100644 --- a/trackers/javascript-tracker/tracker.lite.config.ts +++ b/trackers/javascript-tracker/tracker.lite.config.ts @@ -21,3 +21,4 @@ export const privacySandbox = false; export const buttonClickTracking = false; export const eventSpecifications = false; export const webVitals = false; +export const elementTracking = false; diff --git a/trackers/javascript-tracker/tracker.test.config.ts b/trackers/javascript-tracker/tracker.test.config.ts index 0aed42800..f587a8fe6 100644 --- a/trackers/javascript-tracker/tracker.test.config.ts +++ b/trackers/javascript-tracker/tracker.test.config.ts @@ -21,3 +21,4 @@ export const privacySandbox = false; export const buttonClickTracking = true; export const eventSpecifications = false; export const webVitals = false; +export const elementTracking = true;