From 5a154c2c984a6ae40c56abf7f0d7800268313e1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Fri, 22 Nov 2024 09:26:26 +0100 Subject: [PATCH 1/2] Add screen tracking plugin and add support for browser plugins --- .../react-native-tracker.newtracker.md | 4 +- ...react-native-tracker.reactnativetracker.md | 8 +- ...act-native-tracker.trackerconfiguration.md | 1 + ...ve-tracker.trackerconfiguration.plugins.md | 13 + .../react-native-tracker.api.md | 13 +- ...ssue-screen-tracking_2024-11-26-13-58.json | 10 + ...ssue-screen-tracking_2024-11-26-13-58.json | 10 + .../rush/browser-approved-packages.json | 4 + common/config/rush/pnpm-lock.yaml | 91 ++++++ common/config/rush/repo-state.json | 2 +- .../CHANGELOG.json | 5 + .../CHANGELOG.md | 3 + .../browser-plugin-screen-tracking/LICENSE | 29 ++ .../browser-plugin-screen-tracking/README.md | 59 ++++ .../jest.config.js | 6 + .../package.json | 57 ++++ .../rollup.config.js | 67 +++++ .../browser-plugin-screen-tracking/src/api.ts | 273 ++++++++++++++++++ .../src/core.ts | 70 +++++ .../src/index.ts | 2 + .../src/schemata.ts | 9 + .../src/types.ts | 141 +++++++++ .../src/utils.ts | 18 ++ .../test/screen.test.ts | 110 +++++++ .../test/screen_summary.test.ts | 215 ++++++++++++++ .../tsconfig.json | 3 + rush.json | 6 + trackers/react-native-tracker/package.json | 3 + trackers/react-native-tracker/src/plugins.ts | 71 +++++ trackers/react-native-tracker/src/tracker.ts | 57 +++- trackers/react-native-tracker/src/types.ts | 58 ++-- .../test/ecommerce.test.ts | 54 ++++ .../react-native-tracker/test/tracker.test.ts | 76 +++++ 33 files changed, 1507 insertions(+), 41 deletions(-) create mode 100644 api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.plugins.md create mode 100644 common/changes/@snowplow/browser-plugin-screen-tracking/issue-screen-tracking_2024-11-26-13-58.json create mode 100644 common/changes/@snowplow/react-native-tracker/issue-screen-tracking_2024-11-26-13-58.json create mode 100644 plugins/browser-plugin-screen-tracking/CHANGELOG.json create mode 100644 plugins/browser-plugin-screen-tracking/CHANGELOG.md create mode 100644 plugins/browser-plugin-screen-tracking/LICENSE create mode 100644 plugins/browser-plugin-screen-tracking/README.md create mode 100644 plugins/browser-plugin-screen-tracking/jest.config.js create mode 100644 plugins/browser-plugin-screen-tracking/package.json create mode 100644 plugins/browser-plugin-screen-tracking/rollup.config.js create mode 100644 plugins/browser-plugin-screen-tracking/src/api.ts create mode 100644 plugins/browser-plugin-screen-tracking/src/core.ts create mode 100644 plugins/browser-plugin-screen-tracking/src/index.ts create mode 100644 plugins/browser-plugin-screen-tracking/src/schemata.ts create mode 100644 plugins/browser-plugin-screen-tracking/src/types.ts create mode 100644 plugins/browser-plugin-screen-tracking/src/utils.ts create mode 100644 plugins/browser-plugin-screen-tracking/test/screen.test.ts create mode 100644 plugins/browser-plugin-screen-tracking/test/screen_summary.test.ts create mode 100644 plugins/browser-plugin-screen-tracking/tsconfig.json create mode 100644 trackers/react-native-tracker/src/plugins.ts create mode 100644 trackers/react-native-tracker/test/ecommerce.test.ts diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md index da09aff2a..d33967db3 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.newtracker.md @@ -9,14 +9,14 @@ Creates a new tracker instance with the given configuration Signature: ```typescript -export declare function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration): Promise; +export declare function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration & ScreenTrackingConfiguration): Promise; ``` ## Parameters | Parameter | Type | Description | | --- | --- | --- | -| configuration | [TrackerConfiguration](./react-native-tracker.trackerconfiguration.md) & EmitterConfiguration & [SessionConfiguration](./react-native-tracker.sessionconfiguration.md) & [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md) & [EventStoreConfiguration](./react-native-tracker.eventstoreconfiguration.md) | Configuration for the tracker | +| configuration | [TrackerConfiguration](./react-native-tracker.trackerconfiguration.md) & EmitterConfiguration & [SessionConfiguration](./react-native-tracker.sessionconfiguration.md) & [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md) & [EventStoreConfiguration](./react-native-tracker.eventstoreconfiguration.md) & ScreenTrackingConfiguration | Configuration for the tracker | Returns: diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md index 6d8f6dd05..82b9fdcfa 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.reactnativetracker.md @@ -10,7 +10,11 @@ The ReactNativeTracker type ```typescript export declare type ReactNativeTracker = { + namespace: string; readonly trackSelfDescribingEvent: = Record>(argmap: SelfDescribingJson, contexts?: EventContext[]) => void; + readonly trackScreenViewEvent: (argmap: ScreenViewProps, contexts?: EventContext[]) => void; + readonly trackScrollChangedEvent: (argmap: ScrollChangedProps, contexts?: EventContext[]) => void; + readonly trackListItemViewEvent: (argmap: ListItemViewProps, contexts?: EventContext[]) => void; readonly trackStructuredEvent: (argmap: StructuredEvent, contexts?: EventContext[]) => void; readonly trackPageViewEvent: (argmap: PageViewEvent, contexts?: EventContext[]) => void; readonly trackTimingEvent: (argmap: TimingProps, contexts?: EventContext[]) => void; @@ -18,7 +22,7 @@ export declare type ReactNativeTracker = { addGlobalContexts(contexts: Array | Record): void; clearGlobalContexts(): void; removeGlobalContexts(contexts: Array): void; - addPlugin(configuration: CorePluginConfiguration): void; + addPlugin(configuration: BrowserPluginConfiguration): void; flush: () => Promise; readonly setAppId: (appId: string) => void; readonly setPlatform: (value: string) => void; @@ -39,5 +43,5 @@ export declare type ReactNativeTracker = { readonly getSessionState: () => Promise; }; ``` -References: [EventContext](./react-native-tracker.eventcontext.md), [TimingProps](./react-native-tracker.timingprops.md), [MessageNotificationProps](./react-native-tracker.messagenotificationprops.md), [ScreenSize](./react-native-tracker.screensize.md), [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md), [SessionState](./react-native-tracker.sessionstate.md) +References: [EventContext](./react-native-tracker.eventcontext.md), [ScreenViewProps](./react-native-tracker.screenviewprops.md), [ScrollChangedProps](./react-native-tracker.scrollchangedprops.md), [ListItemViewProps](./react-native-tracker.listitemviewprops.md), [TimingProps](./react-native-tracker.timingprops.md), [MessageNotificationProps](./react-native-tracker.messagenotificationprops.md), [ScreenSize](./react-native-tracker.screensize.md), [SubjectConfiguration](./react-native-tracker.subjectconfiguration.md), [SessionState](./react-native-tracker.sessionstate.md) diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.md index d4ccd1cce..0815e0bb7 100644 --- a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.md +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.md @@ -19,4 +19,5 @@ export interface TrackerConfiguration | [appId?](./react-native-tracker.trackerconfiguration.appid.md) | string | (Optional) The application ID | | [encodeBase64?](./react-native-tracker.trackerconfiguration.encodebase64.md) | boolean | (Optional) Whether unstructured events and custom contexts should be base64 encoded. | | [namespace](./react-native-tracker.trackerconfiguration.namespace.md) | string | The namespace of the tracker | +| [plugins?](./react-native-tracker.trackerconfiguration.plugins.md) | BrowserPlugin\[\] | (Optional) Inject plugins which will be evaluated for each event | diff --git a/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.plugins.md b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.plugins.md new file mode 100644 index 000000000..d627b5be3 --- /dev/null +++ b/api-docs/docs/react-native-tracker/markdown/react-native-tracker.trackerconfiguration.plugins.md @@ -0,0 +1,13 @@ + + +[Home](./index.md) > [@snowplow/react-native-tracker](./react-native-tracker.md) > [TrackerConfiguration](./react-native-tracker.trackerconfiguration.md) > [plugins](./react-native-tracker.trackerconfiguration.plugins.md) + +## TrackerConfiguration.plugins property + +Inject plugins which will be evaluated for each event + +Signature: + +```typescript +plugins?: BrowserPlugin[]; +``` diff --git a/api-docs/docs/react-native-tracker/react-native-tracker.api.md b/api-docs/docs/react-native-tracker/react-native-tracker.api.md index 8f8131227..668fa35f1 100644 --- a/api-docs/docs/react-native-tracker/react-native-tracker.api.md +++ b/api-docs/docs/react-native-tracker/react-native-tracker.api.md @@ -4,6 +4,10 @@ ```ts +import { BrowserPlugin } from '@snowplow/browser-tracker-core'; +import { BrowserPluginConfiguration } from '@snowplow/browser-tracker-core'; +import { ScreenTrackingConfiguration } from '@snowplow/browser-plugin-screen-tracking'; + // @public export type ConditionalContextProvider = FilterProvider | RuleSetProvider; @@ -251,7 +255,7 @@ export type MessageNotificationProps = { }; // @public -export function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration): Promise; +export function newTracker(configuration: TrackerConfiguration & EmitterConfiguration & SessionConfiguration & SubjectConfiguration & EventStoreConfiguration & ScreenTrackingConfiguration): Promise; // @public export interface PageViewEvent { @@ -277,7 +281,11 @@ export interface PayloadBuilder { // @public export type ReactNativeTracker = { + namespace: string; readonly trackSelfDescribingEvent: = Record>(argmap: SelfDescribingJson, contexts?: EventContext[]) => void; + readonly trackScreenViewEvent: (argmap: ScreenViewProps, contexts?: EventContext[]) => void; + readonly trackScrollChangedEvent: (argmap: ScrollChangedProps, contexts?: EventContext[]) => void; + readonly trackListItemViewEvent: (argmap: ListItemViewProps, contexts?: EventContext[]) => void; readonly trackStructuredEvent: (argmap: StructuredEvent, contexts?: EventContext[]) => void; readonly trackPageViewEvent: (argmap: PageViewEvent, contexts?: EventContext[]) => void; readonly trackTimingEvent: (argmap: TimingProps, contexts?: EventContext[]) => void; @@ -285,7 +293,7 @@ export type ReactNativeTracker = { addGlobalContexts(contexts: Array | Record): void; clearGlobalContexts(): void; removeGlobalContexts(contexts: Array): void; - addPlugin(configuration: CorePluginConfiguration): void; + addPlugin(configuration: BrowserPluginConfiguration): void; flush: () => Promise; readonly setAppId: (appId: string) => void; readonly setPlatform: (value: string) => void; @@ -426,6 +434,7 @@ export interface TrackerConfiguration { appId?: string; encodeBase64?: boolean; namespace: string; + plugins?: BrowserPlugin[]; } // @public diff --git a/common/changes/@snowplow/browser-plugin-screen-tracking/issue-screen-tracking_2024-11-26-13-58.json b/common/changes/@snowplow/browser-plugin-screen-tracking/issue-screen-tracking_2024-11-26-13-58.json new file mode 100644 index 000000000..58cf5e944 --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-screen-tracking/issue-screen-tracking_2024-11-26-13-58.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-screen-tracking", + "comment": "Add screen tracking plugin and add support for browser plugins (#1394)", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-screen-tracking" +} \ No newline at end of file diff --git a/common/changes/@snowplow/react-native-tracker/issue-screen-tracking_2024-11-26-13-58.json b/common/changes/@snowplow/react-native-tracker/issue-screen-tracking_2024-11-26-13-58.json new file mode 100644 index 000000000..07b248c37 --- /dev/null +++ b/common/changes/@snowplow/react-native-tracker/issue-screen-tracking_2024-11-26-13-58.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/react-native-tracker", + "comment": "Add screen tracking plugin and add support for browser plugins (#1394)", + "type": "none" + } + ], + "packageName": "@snowplow/react-native-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 8157b3617..1445937ab 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -126,6 +126,10 @@ "name": "@snowplow/browser-plugin-privacy-sandbox", "allowedCategories": [ "trackers" ] }, + { + "name": "@snowplow/browser-plugin-screen-tracking", + "allowedCategories": [ "trackers" ] + }, { "name": "@snowplow/browser-plugin-site-tracking", "allowedCategories": [ "trackers" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index fc5f51ccc..ffa132588 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -1546,6 +1546,88 @@ importers: specifier: ~4.6.2 version: 4.6.4 + ../../plugins/browser-plugin-screen-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 + uuid: + specifier: ^10.0.0 + version: 10.0.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: ~28.1.1 + version: 28.1.8 + '@types/jsdom': + specifier: ~16.2.14 + version: 16.2.15 + '@types/lodash': + specifier: ~4.14.180 + version: 4.14.202 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@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: ~28.1.3 + version: 28.1.3(@types/node@20.16.3)(ts-node@10.9.2(@types/node@20.16.3)(typescript@4.6.4)) + jest-environment-jsdom: + specifier: ~28.1.3 + version: 28.1.3 + jest-environment-jsdom-global: + specifier: ~4.0.0 + version: 4.0.0(jest-environment-jsdom@28.1.3) + jest-standard-reporter: + specifier: ~2.0.0 + version: 2.0.0 + lodash: + specifier: ~4.17.21 + version: 4.17.21 + 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/plugin-transform-runtime@7.25.9(@babel/core@7.25.2))(@babel/preset-env@7.26.0(@babel/core@7.25.2))(@babel/runtime@7.25.6)(rollup@2.70.2)(typescript@4.6.4) + ts-jest: + specifier: ~28.0.8 + version: 28.0.8(@babel/core@7.25.2)(@jest/types@28.1.3)(babel-jest@28.1.3(@babel/core@7.25.2))(jest@28.1.3(@types/node@20.16.3)(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-site-tracking: dependencies: '@snowplow/browser-tracker-core': @@ -2361,6 +2443,12 @@ importers: '@react-native-async-storage/async-storage': specifier: ~2.0.0 version: 2.0.0(react-native@0.74.5(@babel/preset-env@7.26.0(@babel/core@7.25.2))(@types/react@18.3.12)(encoding@0.1.13)(react@18.2.0)) + '@snowplow/browser-plugin-screen-tracking': + specifier: workspace:* + version: link:../../plugins/browser-plugin-screen-tracking + '@snowplow/browser-tracker-core': + specifier: workspace:* + version: link:../../libraries/browser-tracker-core '@snowplow/tracker-core': specifier: workspace:* version: link:../../libraries/tracker-core @@ -2374,6 +2462,9 @@ importers: specifier: ^10.0.0 version: 10.0.0 devDependencies: + '@snowplow/browser-plugin-snowplow-ecommerce': + specifier: workspace:* + version: link:../../plugins/browser-plugin-snowplow-ecommerce '@types/jest': specifier: ~28.1.1 version: 28.1.8 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index d50fdf0a1..df90b414c 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": "50fa04b4f2a1f9fdbf83a84ce901759859832bfd", + "pnpmShrinkwrapHash": "d1933251824ef3eb837586f089856ce0f6036111", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/plugins/browser-plugin-screen-tracking/CHANGELOG.json b/plugins/browser-plugin-screen-tracking/CHANGELOG.json new file mode 100644 index 000000000..ae43026dc --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/CHANGELOG.json @@ -0,0 +1,5 @@ +{ + "name": "@snowplow/browser-plugin-screen-tracking", + "entries": [ + ] +} diff --git a/plugins/browser-plugin-screen-tracking/CHANGELOG.md b/plugins/browser-plugin-screen-tracking/CHANGELOG.md new file mode 100644 index 000000000..b16611867 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change Log - @snowplow/browser-plugin-snowplow-screen-tracking + +This log was last generated on Fri, 01 Nov 2024 10:35:07 GMT and should not be manually modified. diff --git a/plugins/browser-plugin-screen-tracking/LICENSE b/plugins/browser-plugin-screen-tracking/LICENSE new file mode 100644 index 000000000..76f1946ea --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +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. diff --git a/plugins/browser-plugin-screen-tracking/README.md b/plugins/browser-plugin-screen-tracking/README.md new file mode 100644 index 000000000..f89baa099 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/README.md @@ -0,0 +1,59 @@ +# Snowplow Screen Tracking Plugin + +[![npm version][npm-image]][npm-url] +[![License][license-image]](LICENSE) + +Browser Plugin to be used with `@snowplow/browser-tracker`. The plugin is already included in `@snowplow/react-native-tracker`. + +This plugin is the recommended way to track screen view events. + +## 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-snowplow-screen-tracking +``` + +## Usage + +Initialize your tracker with the ScreenTrackingPlugin: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { ScreenTrackingPlugin } from '@snowplow/browser-plugin-screen-tracking'; + +newTracker('sp1', '{{collector_url}}', { + appId: 'my-app-id', + plugins: [ ScreenTrackingPlugin() ], +}); +``` + +For a full API reference, you can read the plugin [documentation page](https://docs.snowplow.io/docs/collecting-data/collecting-from-own-applications/javascript-trackers/browser-tracker/browser-tracker-v3-reference/plugins/screen-tracking/). + +## Copyright and license + +Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). + +Copyright (c) 2022 Snowplow Analytics Ltd. + +All rights reserved. + +[npm-url]: https://www.npmjs.com/package/@snowplow/browser-plugin-screen-tracking +[npm-image]: https://img.shields.io/npm/v/@snowplow/browser-plugin-screen-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-screen-tracking diff --git a/plugins/browser-plugin-screen-tracking/jest.config.js b/plugins/browser-plugin-screen-tracking/jest.config.js new file mode 100644 index 000000000..87d15da9b --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/jest.config.js @@ -0,0 +1,6 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + setupFilesAfterEnv: ['../../setupTestGlobals.ts'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-screen-tracking/package.json b/plugins/browser-plugin-screen-tracking/package.json new file mode 100644 index 000000000..bf82d80f2 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/package.json @@ -0,0 +1,57 @@ +{ + "name": "@snowplow/browser-plugin-screen-tracking", + "version": "4.0.1", + "description": "Snowplow screen tracking", + "homepage": "http://bit.ly/sp-js", + "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": "Peter Perlepes", + "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", + "uuid": "^10.0.0" + }, + "devDependencies": { + "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", + "@rollup/plugin-commonjs": "~21.0.2", + "@rollup/plugin-node-resolve": "~13.1.3", + "@types/jest": "~28.1.1", + "@types/jsdom": "~16.2.14", + "@types/lodash": "~4.14.180", + "@types/uuid": "^10.0.0", + "@typescript-eslint/eslint-plugin": "~5.15.0", + "@typescript-eslint/parser": "~5.15.0", + "eslint": "~8.11.0", + "jest": "~28.1.3", + "jest-environment-jsdom": "~28.1.3", + "jest-environment-jsdom-global": "~4.0.0", + "jest-standard-reporter": "~2.0.0", + "lodash": "~4.17.21", + "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": "~28.0.8", + "typescript": "~4.6.2" + }, + "peerDependencies": { + "@snowplow/browser-tracker": "~4.0.1" + } +} diff --git a/plugins/browser-plugin-screen-tracking/rollup.config.js b/plugins/browser-plugin-screen-tracking/rollup.config.js new file mode 100644 index 000000000..817d14255 --- /dev/null +++ b/plugins/browser-plugin-screen-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 = 'snowplowScreenTracking'; + +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-screen-tracking/src/api.ts b/plugins/browser-plugin-screen-tracking/src/api.ts new file mode 100644 index 000000000..761286e43 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/src/api.ts @@ -0,0 +1,273 @@ +import { BrowserPlugin, BrowserTracker, dispatchToTrackersInCollection } from '@snowplow/browser-tracker-core'; +import { CommonEventProperties, Payload } from '@snowplow/tracker-core'; +import { + buildListItemViewEvent, + buildScreenEndEvent, + buildScreenEntity, + buildScreenSummaryEntity, + buildScreenViewEvent, + buildScrollChangedEvent, +} from './core'; +import { + ListItemViewProps, + ScreenProps, + ScreenSummaryProps, + ScreenTrackingConfiguration, + ScreenViewProps, + ScrollChangedProps, +} from './types'; +import { + BACKGROUND_EVENT_SCHEMA, + FOREGROUND_EVENT_SCHEMA, + LIST_ITEM_VIEW_EVENT_SCHEMA, + SCREEN_END_EVENT_SCHEMA, + SCREEN_VIEW_EVENT_SCHEMA, + SCROLL_CHANGED_EVENT_SCHEMA, +} from './schemata'; +import { getUsefulSchemaAndData } from './utils'; +import { v4 as uuidv4 } from 'uuid'; + +const _trackers: Record = {}; + +/** + * Adds screen tracking + */ +export function ScreenTrackingPlugin({ + screenEngagementAutotracking = true, + screenContext = true, +}: ScreenTrackingConfiguration = {}): BrowserPlugin { + let trackerId: string; + let screenEntity: ScreenProps | undefined; + let screenSummary: ScreenSummaryProps | undefined; + let lastUpdate: Date | undefined; + let inForeground = true; + + // Update the screen summary foreground and background durations based on the time since the last update + const updateScreenSummaryDurations = () => { + if (screenSummary !== undefined && lastUpdate !== undefined) { + const timeDiffSec = (new Date().getTime() - lastUpdate.getTime()) / 1000; + if (inForeground) { + screenSummary.foreground_sec += timeDiffSec; + } else { + screenSummary.background_sec = (screenSummary.background_sec ?? 0) + timeDiffSec; + } + lastUpdate = new Date(); + } + }; + + // Update the screen summary scroll values based on the current event + const updateScreenSummaryScroll = (scrollChanged: ScrollChangedProps) => { + if (screenSummary) { + if (scrollChanged.yOffset !== undefined) { + const maxYOffset = scrollChanged.yOffset + (scrollChanged.viewHeight ?? 0); + screenSummary.max_y_offset = Math.max(maxYOffset, screenSummary.max_y_offset ?? maxYOffset); + + screenSummary.min_y_offset = Math.min( + scrollChanged.yOffset, + screenSummary.min_y_offset ?? scrollChanged.yOffset + ); + } + + if (scrollChanged.xOffset !== undefined) { + const maxXOffset = scrollChanged.xOffset + (scrollChanged.viewWidth ?? 0); + screenSummary.max_x_offset = Math.max(maxXOffset, screenSummary.max_x_offset ?? maxXOffset); + + screenSummary.min_x_offset = Math.min( + scrollChanged.xOffset, + screenSummary.min_x_offset ?? scrollChanged.xOffset + ); + } + + if (scrollChanged.contentHeight !== undefined) { + screenSummary.content_height = Math.max(scrollChanged.contentHeight, screenSummary.content_height ?? 0); + } + + if (scrollChanged.contentWidth !== undefined) { + screenSummary.content_width = Math.max(scrollChanged.contentWidth, screenSummary.content_width ?? 0); + } + } + }; + + // Update the screen summary list items based on the current event + const updateScreenSummaryListItems = (listItemView: ListItemViewProps) => { + if (screenSummary) { + screenSummary.last_item_index = Math.max( + listItemView.index, + screenSummary?.last_item_index ?? listItemView.index + ); + if (listItemView.itemsCount !== undefined) { + screenSummary.items_count = Math.max( + listItemView.itemsCount, + screenSummary.items_count ?? listItemView.itemsCount + ); + } + listItemView.index; + } + }; + + // Update the current screen view state + const updateScreenView = (screenView: ScreenViewProps) => { + if (screenEntity && !screenView.previousId && !screenView.previousName) { + screenView.previousId = screenEntity.id; + screenView.previousName = screenEntity.name; + screenView.previousType = screenEntity.type; + } + + const { name, id, type } = screenView; + if (name && id) { + screenEntity = { name, id, type }; + screenSummary = { foreground_sec: 0 }; + lastUpdate = new Date(); + } else { + screenEntity = undefined; + screenSummary = undefined; + lastUpdate = undefined; + } + }; + + return { + activateBrowserPlugin: (tracker) => { + trackerId = tracker.id; + _trackers[trackerId] = tracker; + }, + + beforeTrack: (payload) => { + const schemaAndData = getUsefulSchemaAndData(payload); + if (schemaAndData) { + const { schema, data, eventPayload } = schemaAndData; + + // For screen view events, we need to update the current state, + // and fill in missing previous references + if (schema === SCREEN_VIEW_EVENT_SCHEMA) { + const screenView = data as ScreenViewProps; + updateScreenView(screenView); + + // Replace the event payload with the updated screen view + payload.addJson('ue_px', 'ue_pr', { + ...eventPayload, + data: { schema, data: screenView }, + }); + } + + // For screen end events, we need to attach the screen summary entity + // These events are skipped if there is no screen summary to attach. + else if (schema === SCREEN_END_EVENT_SCHEMA) { + if (screenSummary && screenEngagementAutotracking) { + updateScreenSummaryDurations(); + payload.addContextEntity(buildScreenSummaryEntity(screenSummary)); + } else { + payload.add('__filter__', true); + } + } + + // For foreground events, we need to attach the screen summary entity + else if (schema == FOREGROUND_EVENT_SCHEMA && screenEngagementAutotracking) { + updateScreenSummaryDurations(); + inForeground = true; + if (screenSummary) { + payload.addContextEntity(buildScreenSummaryEntity(screenSummary)); + } + } + + // For background events, we need to attach the screen summary entity + else if (schema == BACKGROUND_EVENT_SCHEMA && screenEngagementAutotracking) { + updateScreenSummaryDurations(); + inForeground = false; + if (screenSummary) { + payload.addContextEntity(buildScreenSummaryEntity(screenSummary)); + } + } + + // For list item view events, we need to update the current state for screen summary + // These events are skipped if screenEngagementAutotracking is enabled. + else if (schema == LIST_ITEM_VIEW_EVENT_SCHEMA && screenEngagementAutotracking) { + const listItemView = data as ListItemViewProps; + updateScreenSummaryListItems(listItemView); + payload.add('__filter__', true); + } + + // For scroll changed events, we need to update the current state for screen summary + // These events are skipped if screenEngagementAutotracking is enabled. + else if (schema == SCROLL_CHANGED_EVENT_SCHEMA && screenEngagementAutotracking) { + const scrollChanged = data as ScrollChangedProps; + updateScreenSummaryScroll(scrollChanged); + payload.add('__filter__', true); + } + } + + // For all events, we need to attach the screen entity if screenContext is enabled + if (screenEntity && screenContext) { + payload.addContextEntity(buildScreenEntity(screenEntity)); + } + }, + + filter: (payload: Payload) => { + // Skip events that have been filtered out in the beforeTrack hook + return payload['__filter__'] === undefined; + }, + }; +} + +/** + * Track a screen view event. + * If screen engagement tracking is enabled, will also track a `screen_end` event with the screen summary information of the previous screen view. + * + * Schema: `iglu:com.snowplowanalytics.mobile/screen_view/jsonschema/1-0-0` + * + * @param props - The properties of the screen view event + * @param trackers - The tracker identifiers which the event will be sent to + */ +export function trackScreenView( + props: ScreenViewProps & CommonEventProperties, + trackers: Array = Object.keys(_trackers) +) { + const { context = [], timestamp, ...screenViewAttributes } = props; + if (!screenViewAttributes.id) { + screenViewAttributes.id = uuidv4(); + } + + dispatchToTrackersInCollection(trackers, _trackers, (t) => { + t.core.track(buildScreenEndEvent(), context, timestamp); + t.core.track(buildScreenViewEvent(screenViewAttributes), context, timestamp); + }); +} + +/** + * Event tracking the view of an item in a list. + * If screen engagement tracking is enabled, the list item view events will be aggregated into a `screen_summary` entity. + * + * Schema: `iglu:com.snowplowanalytics.mobile/list_item_view/jsonschema/1-0-0` + * + * @param props - The properties of the event + * @param trackers - The tracker identifiers which the event will be sent to + */ +export function trackListItemView( + props: ListItemViewProps & CommonEventProperties, + trackers: Array = Object.keys(_trackers) +) { + const { context = [], timestamp, ...listItemViewAttributes } = props; + + dispatchToTrackersInCollection(trackers, _trackers, (t) => { + t.core.track(buildListItemViewEvent(listItemViewAttributes), context, timestamp); + }); +} + +/** + * Event tracked when a scroll view's scroll position changes. + * If screen engagement tracking is enabled, the scroll changed events will be aggregated into a `screen_summary` entity. + * + * Schema: `iglu:com.snowplowanalytics.mobile/scroll_changed/jsonschema/1-0-0` + * + * @param props - The properties of the event + * @param trackers - The tracker identifiers which the event will be sent to + */ +export function trackScrollChanged( + props: ScrollChangedProps & CommonEventProperties, + trackers: Array = Object.keys(_trackers) +) { + const { context = [], timestamp, ...scrollChangedAttributes } = props; + + dispatchToTrackersInCollection(trackers, _trackers, (t) => { + t.core.track(buildScrollChangedEvent(scrollChangedAttributes), context, timestamp); + }); +} diff --git a/plugins/browser-plugin-screen-tracking/src/core.ts b/plugins/browser-plugin-screen-tracking/src/core.ts new file mode 100644 index 000000000..3cbba124d --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/src/core.ts @@ -0,0 +1,70 @@ +import { buildSelfDescribingEvent } from '@snowplow/tracker-core'; +import { LIST_ITEM_VIEW_EVENT_SCHEMA, SCREEN_END_EVENT_SCHEMA, SCREEN_ENTITY_SCHEMA, SCREEN_SUMMARY_ENTITY_SCHEMA, SCREEN_VIEW_EVENT_SCHEMA, SCROLL_CHANGED_EVENT_SCHEMA } from './schemata'; +import { ListItemViewProps, ScreenProps, ScreenSummaryProps, ScreenViewProps, ScrollChangedProps } from './types'; + +export function buildScreenViewEvent(event: ScreenViewProps) { + return buildSelfDescribingEvent({ + event: { + schema: SCREEN_VIEW_EVENT_SCHEMA, + data: removeEmptyProperties({ ...event }), + }, + }); +} + +export function buildScreenEndEvent() { + return buildSelfDescribingEvent({ + event: { + schema: SCREEN_END_EVENT_SCHEMA, + data: {}, + }, + }); +} + +export function buildListItemViewEvent(event: ListItemViewProps) { + return buildSelfDescribingEvent({ + event: { + schema: LIST_ITEM_VIEW_EVENT_SCHEMA, + data: removeEmptyProperties({ ...event }), + }, + }); +} + +export function buildScrollChangedEvent(event: ScrollChangedProps) { + return buildSelfDescribingEvent({ + event: { + schema: SCROLL_CHANGED_EVENT_SCHEMA, + data: removeEmptyProperties({ ...event }), + }, + }); +} + +export function buildScreenEntity(entity: ScreenProps) { + return { + schema: SCREEN_ENTITY_SCHEMA, + data: removeEmptyProperties({ ...entity }), + }; +} + +export function buildScreenSummaryEntity(entity: ScreenSummaryProps) { + return { + schema: SCREEN_SUMMARY_ENTITY_SCHEMA, + data: removeEmptyProperties({ ...entity }), + }; +} + +/** + * Returns a copy of a JSON with undefined and null properties removed + * + * @param event - Object to clean + * @param exemptFields - Set of fields which should not be removed even if empty + * @returns A cleaned copy of eventJson + */ +function removeEmptyProperties(event: Record): Record { + const ret: Record = {}; + for (const k in event) { + if (event[k] !== null && typeof event[k] !== 'undefined') { + ret[k] = event[k]; + } + } + return ret; +} diff --git a/plugins/browser-plugin-screen-tracking/src/index.ts b/plugins/browser-plugin-screen-tracking/src/index.ts new file mode 100644 index 000000000..4d4b4e299 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/src/index.ts @@ -0,0 +1,2 @@ +export * from './api'; +export * from './types'; diff --git a/plugins/browser-plugin-screen-tracking/src/schemata.ts b/plugins/browser-plugin-screen-tracking/src/schemata.ts new file mode 100644 index 000000000..d5710591e --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/src/schemata.ts @@ -0,0 +1,9 @@ +export const SCREEN_VIEW_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/screen_view/jsonschema/1-0-0'; +export const SCREEN_END_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/screen_end/jsonschema/1-0-0'; +export const FOREGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_foreground/jsonschema/1-0-0'; +export const BACKGROUND_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.snowplow/application_background/jsonschema/1-0-0'; +export const LIST_ITEM_VIEW_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/list_item_view/jsonschema/1-0-0'; +export const SCROLL_CHANGED_EVENT_SCHEMA = 'iglu:com.snowplowanalytics.mobile/scroll_changed/jsonschema/1-0-0'; + +export const SCREEN_ENTITY_SCHEMA = 'iglu:com.snowplowanalytics.mobile/screen/jsonschema/1-0-0'; +export const SCREEN_SUMMARY_ENTITY_SCHEMA = 'iglu:com.snowplowanalytics.mobile/screen_summary/jsonschema/1-0-0'; diff --git a/plugins/browser-plugin-screen-tracking/src/types.ts b/plugins/browser-plugin-screen-tracking/src/types.ts new file mode 100644 index 000000000..22e3da4e1 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/src/types.ts @@ -0,0 +1,141 @@ +export interface ScreenTrackingConfiguration { + /** + * Whether to enable tracking of the screen end event and the screen summary context entity. + * Make sure that you have lifecycle autotracking enabled for screen summary to have complete information. + * @default true + */ + screenEngagementAutotracking?: boolean; + + /** + * Whether to enable tracking of the screen context entity. + * @default true + */ + screenContext?: boolean; +} +/** + * ScreenView event properties + * schema: iglu:com.snowplowanalytics.mobile/screen_view/jsonschema/1-0-0 + */ +export type ScreenViewProps = { + /** + * The name of the screen viewed + */ + name: string; + /** + * The id(UUID) of screen that was viewed + * Will be automatically generated if not provided + */ + id?: string; + /** + * The type of screen that was viewed + */ + type?: string; + /** + * The name of the previous screen that was viewed + */ + previousName?: string; + /** + * The id(UUID) of the previous screen that was viewed + */ + previousId?: string; + /** + * The type of the previous screen that was viewed + */ + previousType?: string; + /** + * The type of transition that led to the screen being viewed + */ + transitionType?: string; +}; + +/** Screen context entity properties. */ +export type ScreenProps = { + /** The name of the screen viewed. */ + name: string; + /** The type of screen that was viewed e.g feed / carousel. */ + type?: string; + /** An ID from the associated screenview event. */ + id: string; + /** iOS specific: class name of the view controller. */ + viewController?: string; + /** iOS specific: class name of the top level view controller. */ + topViewController?: string; + /** Android specific: name of activity. */ + activity?: string; + /** Android specific: name of fragment. */ + fragment?: string; +}; + +/** + * Event tracked when a scroll view's scroll position changes. + * If screen engagement tracking is enabled, the scroll changed events will be aggregated into a `screen_summary` entity. + * + * Schema: `iglu:com.snowplowanalytics.mobile/scroll_changed/jsonschema/1-0-0` + */ +export type ScrollChangedProps = { + /** + * Vertical scroll offset in pixels + */ + yOffset?: number; + /** + * Horizontal scroll offset in pixels. + */ + xOffset?: number; + /** + * The height of the scroll view in pixels + */ + viewHeight?: number; + /** + * The width of the scroll view in pixels + */ + viewWidth?: number; + /** + * The height of the content in the scroll view in pixels + */ + contentHeight?: number; + /** + * The width of the content in the scroll view in pixels + */ + contentWidth?: number; +}; + +/** + * Event tracking the view of an item in a list. + * If screen engagement tracking is enabled, the list item view events will be aggregated into a `screen_summary` entity. + * + * Schema: `iglu:com.snowplowanalytics.mobile/list_item_view/jsonschema/1-0-0` + */ +export type ListItemViewProps = { + /** + * Index of the item in the list + */ + index: number; + /** + * Total number of items in the list + */ + itemsCount?: number; +}; + +/** Schema for an entity tracked with foreground/background/screen_end events with summary statistics about the screen view */ +export type ScreenSummaryProps = { + /** Time in seconds spent on the current screen while the app was in foreground */ + foreground_sec: number; + /** Time in seconds spent on the current screen while the app was in background */ + background_sec?: number; + /** Index of the last viewed item in the list on the screen */ + last_item_index?: number; + /** Total number of items in the list on the screen */ + items_count?: number; + /** Minimum horizontal scroll offset on the scroll view in pixels */ + min_x_offset?: number; + /** Maximum horizontal scroll offset on the scroll view in pixels */ + max_x_offset?: number; + /** Minimum vertical scroll offset on the scroll view in pixels */ + min_y_offset?: number; + /** Maximum vertical scroll offset on the scroll view in pixels */ + max_y_offset?: number; + /** Width of the scroll view in pixels */ + content_width?: number; + /** Height of the scroll view in pixels */ + content_height?: number; +}; diff --git a/plugins/browser-plugin-screen-tracking/src/utils.ts b/plugins/browser-plugin-screen-tracking/src/utils.ts new file mode 100644 index 000000000..faa1baac4 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/src/utils.ts @@ -0,0 +1,18 @@ +import { PayloadBuilder } from '@snowplow/tracker-core'; + +// Returns the "useful" schema, i.e. what would someone want to use to identify events. +// For some events this is the 'e' property but for unstructured events, this is the +// 'schema' from the 'ue_px' field. +export function getUsefulSchemaAndData(sb: PayloadBuilder) { + let eventJson = sb.getJson(); + for (const json of eventJson) { + if (json.keyIfEncoded === 'ue_px' && typeof json.json['data'] === 'object') { + const schema = (json.json['data'] as Record)['schema']; + if (typeof schema == 'string') { + const data = json.json['data'] as Record; + return { schema, data: data.data as Record, eventPayload: data }; + } + } + } + return undefined; +} diff --git a/plugins/browser-plugin-screen-tracking/test/screen.test.ts b/plugins/browser-plugin-screen-tracking/test/screen.test.ts new file mode 100644 index 000000000..bb440fd4e --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/test/screen.test.ts @@ -0,0 +1,110 @@ +import { addTracker, SharedState, EventStore, BrowserTracker } from '@snowplow/browser-tracker-core'; +import { ScreenTrackingPlugin, trackScreenView } from '../src'; +import { newInMemoryEventStore } from '@snowplow/tracker-core'; +import { SCREEN_ENTITY_SCHEMA, SCREEN_VIEW_EVENT_SCHEMA } from '../src/schemata'; + +describe('ScreenTrackingPlugin', () => { + let idx = 1; + let eventStore: EventStore; + let tracker: BrowserTracker | null; + + describe('Enabled screen context tracking', () => { + beforeEach(() => { + eventStore = newInMemoryEventStore({}); + const customFetch = async () => new Response(null, { status: 500 }); + tracker = addTracker(`sp${idx++}`, `sp${idx++}`, 'js-3.0.0', '', new SharedState(), { + plugins: [ScreenTrackingPlugin()], + eventStore, + customFetch, + contexts: { webPage: false }, + }); + }); + + it('adds id and previous screen view references', async () => { + trackScreenView({ + id: '1', + name: 'Home', + }); + + let [{ ue_pr }] = await eventStore.getAllPayloads(); + let event = JSON.parse(ue_pr as string).data; + expect(event.schema).toBe(SCREEN_VIEW_EVENT_SCHEMA); + expect(event.data).toMatchObject({ + name: 'Home', + id: '1', + }); + + trackScreenView({ + name: 'About', + }); + + [, , { ue_pr }] = await eventStore.getAllPayloads(); + event = JSON.parse(ue_pr as string).data; + expect(event.schema).toBe(SCREEN_VIEW_EVENT_SCHEMA); + expect(event.data).toMatchObject({ + name: 'About', + id: expect.any(String), + previousName: 'Home', + previousId: '1', + }); + }); + + it('adds screen context entity to all events', async () => { + trackScreenView({ + id: '1', + name: 'Home', + }); + + let [{ co }] = await eventStore.getAllPayloads(); + let context = JSON.parse(co as string).data; + expect(context).toEqual([ + { + schema: SCREEN_ENTITY_SCHEMA, + data: { + name: 'Home', + id: '1', + }, + }, + ]); + + tracker?.trackPageView(); + + [, { co }] = await eventStore.getAllPayloads(); + context = JSON.parse(co as string).data; + expect(context).toEqual([ + { + schema: SCREEN_ENTITY_SCHEMA, + data: { + name: 'Home', + id: '1', + }, + }, + ]); + }); + }); + + describe('Disabled screen context tracking', () => { + beforeEach(() => { + eventStore = newInMemoryEventStore({}); + const customFetch = async () => new Response(null, { status: 500 }); + tracker = addTracker(`sp${idx++}`, `sp${idx++}`, 'js-3.0.0', '', new SharedState(), { + plugins: [ScreenTrackingPlugin({ screenContext: false })], + eventStore, + customFetch, + contexts: { webPage: false }, + }); + }); + + it('does not add screen context entity to events', async () => { + trackScreenView({ name: 'Home' }); + + let [{ co }] = await eventStore.getAllPayloads(); + expect(co).toBeUndefined(); + + tracker?.trackPageView(); + + [, { co }] = await eventStore.getAllPayloads(); + expect(co).toBeUndefined(); + }); + }); +}); diff --git a/plugins/browser-plugin-screen-tracking/test/screen_summary.test.ts b/plugins/browser-plugin-screen-tracking/test/screen_summary.test.ts new file mode 100644 index 000000000..f86454980 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/test/screen_summary.test.ts @@ -0,0 +1,215 @@ +import { addTracker, SharedState, EventStore, BrowserTracker } from '@snowplow/browser-tracker-core'; +import { ScreenTrackingPlugin, trackListItemView, trackScreenView, trackScrollChanged } from '../src'; +import { buildSelfDescribingEvent, newInMemoryEventStore } from '@snowplow/tracker-core'; +import { BACKGROUND_EVENT_SCHEMA, SCREEN_END_EVENT_SCHEMA, SCREEN_SUMMARY_ENTITY_SCHEMA, SCREEN_VIEW_EVENT_SCHEMA } from '../src/schemata'; + +describe('Screen summary tracking', () => { + let idx = 1; + let eventStore: EventStore; + let tracker: BrowserTracker | null; + + beforeAll(() => { + jest.useFakeTimers(); + }); + + afterAll(() => { + jest.clearAllTimers(); + }); + + describe('Enabled screen engagement tracking', () => { + beforeEach(() => { + eventStore = newInMemoryEventStore({}); + const customFetch = async () => new Response(null, { status: 500 }); + tracker = addTracker(`sp${idx++}`, `sp${idx++}`, 'js-3.0.0', '', new SharedState(), { + plugins: [ScreenTrackingPlugin()], + eventStore, + customFetch, + contexts: { webPage: false }, + }); + }); + + it('adds a screen summary entity to screen end event', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + trackScreenView({ name: 'Home' }); + + jest.setSystemTime(new Date('2022-04-17T00:00:15.000Z')); + trackScreenView({ name: 'About' }); + + const [, { ue_pr, co }] = await eventStore.getAllPayloads(); + const event = JSON.parse(ue_pr as string).data; + expect(event.schema).toBe(SCREEN_END_EVENT_SCHEMA); + + const context = JSON.parse(co as string).data; + const screenSummary = context.find((c: any) => c.schema === SCREEN_SUMMARY_ENTITY_SCHEMA); + expect(screenSummary.data).toMatchObject({ + foreground_sec: 15, + }); + + jest.setSystemTime(new Date('2022-04-17T00:00:45.000Z')); + trackScreenView({ name: 'Contact' }); + + const [, , , { ue_pr: ue_pr2, co: co2 }] = await eventStore.getAllPayloads(); + const event2 = JSON.parse(ue_pr2 as string).data; + expect(event2.schema).toBe(SCREEN_END_EVENT_SCHEMA); + + const context2 = JSON.parse(co2 as string).data; + const screenSummary2 = context2.find((c: any) => c.schema === SCREEN_SUMMARY_ENTITY_SCHEMA); + expect(screenSummary2.data).toMatchObject({ + foreground_sec: 30, + }); + }); + + it('tracks both background and foreground time', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + trackScreenView({ name: 'Home' }); + + jest.setSystemTime(new Date('2022-04-17T00:00:15.000Z')); + tracker?.core.track( + buildSelfDescribingEvent({ + event: { + schema: BACKGROUND_EVENT_SCHEMA, + data: {}, + }, + }) + ); + + jest.setSystemTime(new Date('2022-04-17T00:00:25.000Z')); + trackScreenView({ name: 'About' }); + + const [, { ue_pr, co }, { ue_pr: ue_pr2, co: co2 }] = await eventStore.getAllPayloads(); + + const event = JSON.parse(ue_pr as string).data; + expect(event.schema).toBe(BACKGROUND_EVENT_SCHEMA); + + const context = JSON.parse(co as string).data; + const screenSummary = context.find((c: any) => c.schema === SCREEN_SUMMARY_ENTITY_SCHEMA); + expect(screenSummary.data).toMatchObject({ + foreground_sec: 15, + }); + + const event2 = JSON.parse(ue_pr2 as string).data; + expect(event2.schema).toBe(SCREEN_END_EVENT_SCHEMA); + + const context2 = JSON.parse(co2 as string).data; + const screenSummary2 = context2.find((c: any) => c.schema === SCREEN_SUMMARY_ENTITY_SCHEMA); + expect(screenSummary2.data).toMatchObject({ + foreground_sec: 15, + background_sec: 10, + }); + }); + + it('adds scroll information to screen summary entity', async () => { + trackScreenView({ name: 'Home' }); + + trackScrollChanged({ + yOffset: 10, + xOffset: 0, + viewHeight: 1000, + viewWidth: 100, + contentHeight: 2000, + contentWidth: 1000, + }); + + trackScrollChanged({ + yOffset: 500, + xOffset: 10, + viewHeight: 1000, + viewWidth: 100, + contentHeight: 2000, + contentWidth: 1000, + }); + + trackScreenView({ name: 'About' }); + + const payloads = await eventStore.getAllPayloads(); + expect(payloads.length).toBe(3); + const [, { co }] = payloads; + const context = JSON.parse(co as string).data; + const screenSummary = context.find((c: any) => c.schema === SCREEN_SUMMARY_ENTITY_SCHEMA); + expect(screenSummary.data).toMatchObject({ + min_x_offset: 0, + max_x_offset: 10 + 100, + min_y_offset: 10, + max_y_offset: 500 + 1000, + content_height: 2000, + content_width: 1000, + }); + }); + + it('adds list item view information to screen summary entity', async () => { + trackScreenView({ name: 'Home' }); + + trackListItemView({ + index: 0, + itemsCount: 10, + }); + + trackListItemView({ + index: 5, + itemsCount: 10, + }); + + trackScreenView({ name: 'About' }); + + const payloads = await eventStore.getAllPayloads(); + expect(payloads.length).toBe(3); + const [, { co }] = payloads; + const context = JSON.parse(co as string).data; + const screenSummary = context.find((c: any) => c.schema === SCREEN_SUMMARY_ENTITY_SCHEMA); + expect(screenSummary.data).toMatchObject({ + last_item_index: 5, + items_count: 10, + }); + }); + }); + + describe('Disabled screen engagement tracking', () => { + beforeEach(() => { + eventStore = newInMemoryEventStore({}); + const customFetch = async () => new Response(null, { status: 500 }); + tracker = addTracker(`sp${idx++}`, `sp${idx++}`, 'js-3.0.0', '', new SharedState(), { + plugins: [ScreenTrackingPlugin({ screenEngagementAutotracking: false })], + eventStore, + customFetch, + contexts: { webPage: false }, + }); + }); + + it('does not add a screen end event', async () => { + jest.setSystemTime(new Date('2022-04-17T00:00:00.000Z')); + trackScreenView({ name: 'Home' }); + + jest.setSystemTime(new Date('2022-04-17T00:00:15.000Z')); + trackScreenView({ name: 'About' }); + + const payloads = await eventStore.getAllPayloads(); + expect(payloads.length).toBe(2); + const [, { ue_pr }] = payloads; + const event = JSON.parse(ue_pr as string).data; + expect(event.schema).toBe(SCREEN_VIEW_EVENT_SCHEMA); + }); + + it('tracks scroll and list item view events', async () => { + trackScreenView({ name: 'Home' }); + + trackScrollChanged({ + yOffset: 10, + xOffset: 0, + viewHeight: 1000, + viewWidth: 100, + contentHeight: 2000, + contentWidth: 1000, + }); + + trackListItemView({ + index: 0, + itemsCount: 10, + }); + + trackScreenView({ name: 'About' }); + + const payloads = await eventStore.getAllPayloads(); + expect(payloads.length).toBe(4); + }); + }); +}); diff --git a/plugins/browser-plugin-screen-tracking/tsconfig.json b/plugins/browser-plugin-screen-tracking/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/browser-plugin-screen-tracking/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/rush.json b/rush.json index d27d4df68..1251e9e73 100644 --- a/rush.json +++ b/rush.json @@ -540,6 +540,12 @@ "projectFolder": "plugins/browser-plugin-event-specifications", "reviewCategory": "plugins", "versionPolicyName": "tracker" + }, + { + "packageName": "@snowplow/browser-plugin-screen-tracking", + "projectFolder": "plugins/browser-plugin-screen-tracking", + "reviewCategory": "plugins", + "versionPolicyName": "tracker" } ] } diff --git a/trackers/react-native-tracker/package.json b/trackers/react-native-tracker/package.json index 159a4b961..9e5fa46f4 100644 --- a/trackers/react-native-tracker/package.json +++ b/trackers/react-native-tracker/package.json @@ -48,12 +48,15 @@ }, "dependencies": { "@snowplow/tracker-core": "workspace:*", + "@snowplow/browser-tracker-core": "workspace:*", + "@snowplow/browser-plugin-screen-tracking": "workspace:*", "@react-native-async-storage/async-storage": "~2.0.0", "react-native-get-random-values": "~1.11.0", "tslib": "^2.3.1", "uuid": "^10.0.0" }, "devDependencies": { + "@snowplow/browser-plugin-snowplow-ecommerce": "workspace:*", "@typescript-eslint/eslint-plugin": "~5.15.0", "@typescript-eslint/parser": "~5.15.0", "eslint": "~8.11.0", diff --git a/trackers/react-native-tracker/src/plugins.ts b/trackers/react-native-tracker/src/plugins.ts new file mode 100644 index 000000000..607ec3171 --- /dev/null +++ b/trackers/react-native-tracker/src/plugins.ts @@ -0,0 +1,71 @@ +import { BrowserPluginConfiguration, BrowserTracker, ParsedIdCookie } from '@snowplow/browser-tracker-core'; +import { TrackerCore } from '@snowplow/tracker-core'; + +/** + * Creates a fake BrowserTracker from a TrackerCore instance in order to use in browser plugins. + * Most of the methods are not implemented and will throw an error if called. + * However, our plugins mostly only call the `core` methods. + */ +function toBrowserTracker(namespace: string, core: TrackerCore): BrowserTracker { + const notImplemented = () => { + throw new Error('Not implemented in React Native'); + }; + return { + id: namespace, + namespace, + core, + sharedState: { + bufferFlushers: [], + hasLoaded: true, + registeredOnLoadHandlers: [], + }, + getDomainSessionIndex: () => 0, + getPageViewId: () => '', + getTabId: () => null, + getCookieName: () => '', + getUserId: () => undefined, + getDomainUserId: () => '', + getDomainUserInfo: (): ParsedIdCookie => ['', '', 0, 0, 0, undefined, '', '', '', undefined, 0], + setReferrerUrl: () => notImplemented, + setCustomUrl: () => notImplemented, + setDocumentTitle: () => notImplemented, + discardHashTag: () => notImplemented, + discardBrace: () => notImplemented, + setCookiePath: () => notImplemented, + setVisitorCookieTimeout: () => notImplemented, + newSession: () => notImplemented, + crossDomainLinker: () => notImplemented, + enableActivityTracking: () => notImplemented, + enableActivityTrackingCallback: () => notImplemented, + disableActivityTracking: () => notImplemented, + disableActivityTrackingCallback: () => notImplemented, + updatePageActivity: () => notImplemented, + setOptOutCookie: () => notImplemented, + setUserId: () => notImplemented, + setUserIdFromLocation: () => notImplemented, + setUserIdFromReferrer: () => notImplemented, + setUserIdFromCookie: () => notImplemented, + setCollectorUrl: () => notImplemented, + setBufferSize: () => notImplemented, + flushBuffer: () => notImplemented, + preservePageViewId: () => notImplemented, + preservePageViewIdForUrl: () => notImplemented, + trackPageView: () => notImplemented, + disableAnonymousTracking: () => notImplemented, + enableAnonymousTracking: () => notImplemented, + clearUserData: () => notImplemented, + addPlugin: () => notImplemented, + }; +} + +export function newPlugins(namespace: string, core: TrackerCore) { + return { + addPlugin: (plugin: BrowserPluginConfiguration) => { + core.addPlugin(plugin); + if (plugin.plugin.activateBrowserPlugin) { + const browserTracker = toBrowserTracker(namespace, core); + plugin.plugin.activateBrowserPlugin?.(browserTracker); + } + }, + }; +} diff --git a/trackers/react-native-tracker/src/tracker.ts b/trackers/react-native-tracker/src/tracker.ts index ac5013d0e..94474339d 100644 --- a/trackers/react-native-tracker/src/tracker.ts +++ b/trackers/react-native-tracker/src/tracker.ts @@ -4,15 +4,21 @@ import { newEmitter } from '@snowplow/tracker-core'; import { newReactNativeEventStore } from './event_store'; import { newTrackEventFunctions } from './events'; import { newSubject } from './subject'; +import { ScreenTrackingConfiguration, ScreenTrackingPlugin, trackListItemView, trackScreenView, trackScrollChanged } from '@snowplow/browser-plugin-screen-tracking'; import { + EventContext, EventStoreConfiguration, + ListItemViewProps, ReactNativeTracker, + ScreenViewProps, + ScrollChangedProps, SessionConfiguration, SubjectConfiguration, TrackerConfiguration, } from './types'; import { newSessionPlugin } from './plugins/session'; +import { newPlugins } from './plugins'; const initializedTrackers: Record = {}; @@ -26,7 +32,8 @@ export async function newTracker( EmitterConfiguration & SessionConfiguration & SubjectConfiguration & - EventStoreConfiguration + EventStoreConfiguration & + ScreenTrackingConfiguration ): Promise { const { namespace, appId, encodeBase64 = false } = configuration; if (configuration.eventStore === undefined) { @@ -37,13 +44,8 @@ export async function newTracker( const callback = (payload: PayloadBuilder): void => { emitter.input(payload.build()); }; - const core = trackerCore({ base64: encodeBase64, callback }); - const subject = newSubject(core, configuration); - core.addPlugin(subject.subjectPlugin); - - const sessionPlugin = await newSessionPlugin(configuration); - core.addPlugin(sessionPlugin); + const core = trackerCore({ base64: encodeBase64, callback }); core.setPlatform('mob'); // default platform core.setTrackerVersion('rn-' + version); core.setTrackerNamespace(namespace); @@ -51,22 +53,61 @@ export async function newTracker( core.setAppId(appId); } + const { addPlugin } = newPlugins(namespace, core); + + const sessionPlugin = await newSessionPlugin(configuration); + addPlugin(sessionPlugin); + + const subject = newSubject(core, configuration); + addPlugin(subject.subjectPlugin); + + const screenPlugin = ScreenTrackingPlugin(configuration); + addPlugin({ plugin: screenPlugin }); + + (configuration.plugins ?? []).forEach((plugin) => addPlugin({ plugin })); + const tracker: ReactNativeTracker = { ...newTrackEventFunctions(core), ...subject.properties, + namespace, setAppId: core.setAppId, setPlatform: core.setPlatform, flush: emitter.flush, addGlobalContexts: core.addGlobalContexts, removeGlobalContexts: core.removeGlobalContexts, clearGlobalContexts: core.clearGlobalContexts, - addPlugin: core.addPlugin, getSessionId: sessionPlugin.getSessionId, getSessionIndex: sessionPlugin.getSessionIndex, getSessionUserId: sessionPlugin.getSessionUserId, getSessionState: sessionPlugin.getSessionState, + addPlugin, + trackScreenViewEvent: (argmap: ScreenViewProps, context?: EventContext[]) => + trackScreenView( + { + ...argmap, + context, + }, + [namespace] + ), + trackScrollChangedEvent: (argmap: ScrollChangedProps, context?: EventContext[]) => + trackScrollChanged( + { + ...argmap, + context, + }, + [namespace] + ), + trackListItemViewEvent: (argmap: ListItemViewProps, context?: EventContext[]) => + trackListItemView( + { + ...argmap, + context, + }, + [namespace] + ), }; initializedTrackers[namespace] = { tracker, core }; + return tracker; } diff --git a/trackers/react-native-tracker/src/types.ts b/trackers/react-native-tracker/src/types.ts index a3f91361e..a0b7d94cf 100755 --- a/trackers/react-native-tracker/src/types.ts +++ b/trackers/react-native-tracker/src/types.ts @@ -1,7 +1,7 @@ +import { BrowserPlugin, BrowserPluginConfiguration } from '@snowplow/browser-tracker-core'; import { ConditionalContextProvider, ContextPrimitive, - CorePluginConfiguration, PageViewEvent, SelfDescribingJson, StructuredEvent, @@ -55,6 +55,11 @@ export interface TrackerConfiguration { * @defaultValue false **/ encodeBase64?: boolean; + /** + * Inject plugins which will be evaluated for each event + * @defaultValue [] + */ + plugins?: BrowserPlugin[]; } /** @@ -375,6 +380,10 @@ export interface SessionState { * The ReactNativeTracker type */ export type ReactNativeTracker = { + /** + * The namespace of the tracker + */ + namespace: string; /** * Tracks a self-describing event * @@ -387,32 +396,29 @@ export type ReactNativeTracker = { contexts?: EventContext[] ) => void; - // TODO: - // /** - // * Tracks a screen-view event - // * - // * @param argmap - The screen-view event's properties - // * @param contexts - The array of event contexts - // */ - // readonly trackScreenViewEvent: (argmap: ScreenViewProps, contexts?: EventContext[]) => string | undefined; + /** + * Tracks a screen-view event + * + * @param argmap - The screen-view event's properties + * @param contexts - The array of event contexts + */ + readonly trackScreenViewEvent: (argmap: ScreenViewProps, contexts?: EventContext[]) => void; - // TODO: - // /** - // * Tracks a scroll changed event - // * - // * @param argmap - The scroll changed event's properties - // * @param contexts - The array of event contexts - // */ - // readonly trackScrollChangedEvent: (argmap: ScrollChangedProps, contexts?: EventContext[]) => string | undefined; + /** + * Tracks a scroll changed event + * + * @param argmap - The scroll changed event's properties + * @param contexts - The array of event contexts + */ + readonly trackScrollChangedEvent: (argmap: ScrollChangedProps, contexts?: EventContext[]) => void; - // TODO: - // /** - // * Tracks a list item view event - // * - // * @param argmap - The list item view event's properties - // * @param contexts - The array of event contexts - // */ - // readonly trackListItemViewEvent: (argmap: ListItemViewProps, contexts?: EventContext[]) => string | undefined; + /** + * Tracks a list item view event + * + * @param argmap - The list item view event's properties + * @param contexts - The array of event contexts + */ + readonly trackListItemViewEvent: (argmap: ListItemViewProps, contexts?: EventContext[]) => void; /** * Tracks a structured event @@ -480,7 +486,7 @@ export type ReactNativeTracker = { * Add a plugin into the plugin collection after Core has already been initialised * @param configuration - The plugin to add */ - addPlugin(configuration: CorePluginConfiguration): void; + addPlugin(configuration: BrowserPluginConfiguration): void; /** * Calls flush on all emitters in order to send all queued events to the collector diff --git a/trackers/react-native-tracker/test/ecommerce.test.ts b/trackers/react-native-tracker/test/ecommerce.test.ts new file mode 100644 index 000000000..7fbc06ee5 --- /dev/null +++ b/trackers/react-native-tracker/test/ecommerce.test.ts @@ -0,0 +1,54 @@ +import { newTracker } from '../src'; +import { setEcommerceUser, SnowplowEcommercePlugin, trackProductView } from '@snowplow/browser-plugin-snowplow-ecommerce'; + +function createMockFetch(status: number, requests: Request[]) { + return async (input: Request) => { + requests.push(input); + let response = new Response(null, { status }); + return response; + }; +} + +describe('Tracking ecommerce events using the ecomerce plugin', () => { + let requests: Request[]; + let mockFetch: ReturnType; + + beforeEach(async () => { + requests = []; + mockFetch = createMockFetch(200, requests); + }); + + it('tracks ecommerce events', async () => { + const tracker = await newTracker({ + namespace: 'test', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + plugins: [SnowplowEcommercePlugin()], + }); + + setEcommerceUser({ + id: 'my-user', + email: 'my-email@email.com', + }); + + trackProductView({ + id: 'my-product', + name: 'My Product', + category: 'my-category', + price: 100, + currency: 'USD', + }) + + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + expect(payload.data[0].ue_pr).toBeDefined(); + expect(payload.data[0].ue_pr).toContain('/snowplow_ecommerce_action/'); + expect(payload.data[0].co).toBeDefined(); + expect(payload.data[0].co).toContain('My Product'); + expect(payload.data[0].co).toContain('my-email@email.com'); + }); +}); diff --git a/trackers/react-native-tracker/test/tracker.test.ts b/trackers/react-native-tracker/test/tracker.test.ts index c1aef82b2..e66d1c580 100644 --- a/trackers/react-native-tracker/test/tracker.test.ts +++ b/trackers/react-native-tracker/test/tracker.test.ts @@ -92,6 +92,82 @@ describe('Tracker', () => { expect(await tracker.getSessionUserId()).toBeDefined(); }); + it('tracks screen engagement events', async () => { + const tracker = await newTracker({ + namespace: 'test', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + }); + + tracker.trackScreenViewEvent({ + name: 'Home', + }); + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + const payload = await request?.json(); + expect(payload.data.length).toBe(1); + expect(payload.data[0].ue_pr).toBeDefined(); + expect(payload.data[0].ue_pr).toContain('/screen_view/'); + expect(payload.data[0].co).toContain('/screen/'); + + tracker.trackScrollChangedEvent({ + xOffset: 101, + }); + tracker.trackListItemViewEvent({ + index: 1, + itemsCount: 909, + }); + tracker.trackScreenViewEvent({ name: 'About' }); + await tracker.flush(); + + expect(requests.length).toBe(2); + const [, secondRequest] = requests; + const secondPayload = await secondRequest?.json(); + expect(secondPayload.data.length).toBe(2); + const [screenEndEvent] = secondPayload.data; + expect(screenEndEvent.ue_pr).toBeDefined(); + expect(screenEndEvent.ue_pr).toContain('screen_end'); + expect(screenEndEvent.co).toBeDefined(); + expect(screenEndEvent.co).toContain('screen_summary'); + expect(screenEndEvent.co).toContain('101'); + expect(screenEndEvent.co).toContain('909'); + }); + + it('doesnt track screen engagement events if disabled', async () => { + const tracker = await newTracker({ + namespace: 'test', + endpoint: 'http://localhost:9090', + customFetch: mockFetch, + screenContext: false, + screenEngagementAutotracking: false, + }); + + tracker.trackScreenViewEvent({ + name: 'Home', + }); + tracker.trackScreenViewEvent({ + name: 'About', + }); + await tracker.flush(); + expect(requests.length).toBe(1); + + const [request] = requests; + + const payload = await request?.json(); + expect(payload.data.length).toBe(2); + + const [screen1, screen2] = payload.data; + expect(screen1.ue_pr).toBeDefined(); + expect(screen1.ue_pr).toContain('/screen_view/'); + expect(screen1.co ?? '').not.toContain('/screen/'); + + expect(screen2.ue_pr).toBeDefined(); + expect(screen2.ue_pr).toContain('/screen_view/'); + expect(screen2.co ?? '').not.toContain('/screen/'); + }); + it('adds a tracker plugin', async () => { const tracker = await newTracker({ namespace: 'test', From cee3cd8366facb5090c3d7b67a5b8d7835e1cc27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Fri, 29 Nov 2024 09:40:01 +0100 Subject: [PATCH 2/2] Remove unused line and fix comment --- plugins/browser-plugin-screen-tracking/src/api.ts | 1 - plugins/browser-plugin-screen-tracking/src/utils.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/browser-plugin-screen-tracking/src/api.ts b/plugins/browser-plugin-screen-tracking/src/api.ts index 761286e43..de006c8a9 100644 --- a/plugins/browser-plugin-screen-tracking/src/api.ts +++ b/plugins/browser-plugin-screen-tracking/src/api.ts @@ -101,7 +101,6 @@ export function ScreenTrackingPlugin({ screenSummary.items_count ?? listItemView.itemsCount ); } - listItemView.index; } }; diff --git a/plugins/browser-plugin-screen-tracking/src/utils.ts b/plugins/browser-plugin-screen-tracking/src/utils.ts index faa1baac4..108b996e5 100644 --- a/plugins/browser-plugin-screen-tracking/src/utils.ts +++ b/plugins/browser-plugin-screen-tracking/src/utils.ts @@ -1,7 +1,7 @@ import { PayloadBuilder } from '@snowplow/tracker-core'; // Returns the "useful" schema, i.e. what would someone want to use to identify events. -// For some events this is the 'e' property but for unstructured events, this is the +// For some events this is the 'e' property but for self-describing events, this is the // 'schema' from the 'ue_px' field. export function getUsefulSchemaAndData(sb: PayloadBuilder) { let eventJson = sb.getJson();