From d6f6ccd313abeadef65078e86d3173f9ba8b36b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Thu, 22 Dec 2022 14:01:22 +0100 Subject: [PATCH 1/5] Add plugin to integrate with Kantar FocalMeter --- .../rush/browser-approved-packages.json | 4 + common/config/rush/pnpm-lock.yaml | 49 +++++++ common/config/rush/repo-state.json | 2 +- plugins/browser-plugin-focalmeter/LICENSE | 29 +++++ plugins/browser-plugin-focalmeter/README.md | 56 ++++++++ .../browser-plugin-focalmeter/jest.config.js | 5 + .../browser-plugin-focalmeter/package.json | 53 ++++++++ .../rollup.config.js | 67 ++++++++++ .../browser-plugin-focalmeter/src/index.ts | 91 +++++++++++++ .../test/request.test.ts | 121 ++++++++++++++++++ .../browser-plugin-focalmeter/tsconfig.json | 3 + rush.json | 6 + 12 files changed, 485 insertions(+), 1 deletion(-) create mode 100644 plugins/browser-plugin-focalmeter/LICENSE create mode 100644 plugins/browser-plugin-focalmeter/README.md create mode 100644 plugins/browser-plugin-focalmeter/jest.config.js create mode 100644 plugins/browser-plugin-focalmeter/package.json create mode 100644 plugins/browser-plugin-focalmeter/rollup.config.js create mode 100644 plugins/browser-plugin-focalmeter/src/index.ts create mode 100644 plugins/browser-plugin-focalmeter/test/request.test.ts create mode 100644 plugins/browser-plugin-focalmeter/tsconfig.json diff --git a/common/config/rush/browser-approved-packages.json b/common/config/rush/browser-approved-packages.json index 8dbba135f..a80fc2a9b 100644 --- a/common/config/rush/browser-approved-packages.json +++ b/common/config/rush/browser-approved-packages.json @@ -130,6 +130,10 @@ "name": "@snowplow/browser-plugin-youtube-tracking", "allowedCategories": [ "trackers" ] }, + { + "name": "@snowplow/browser-plugin-focalmeter", + "allowedCategories": [ "trackers" ] + }, { "name": "@snowplow/browser-tracker", "allowedCategories": [ "plugins", "trackers" ] diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index c047e77ff..af812adfb 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -572,6 +572,55 @@ importers: ts-jest: 27.1.3_60149d457e34ffba7d4e845dde6a1263 typescript: 4.6.2 + ../../plugins/browser-plugin-focalmeter: + specifiers: + '@ampproject/rollup-plugin-closure-compiler': ~0.27.0 + '@rollup/plugin-commonjs': ~21.0.2 + '@rollup/plugin-node-resolve': ~13.1.3 + '@snowplow/browser-tracker-core': workspace:* + '@snowplow/tracker-core': workspace:* + '@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 + tslib: ^2.3.1 + typescript: ~4.6.2 + dependencies: + '@snowplow/browser-tracker-core': link:../../libraries/browser-tracker-core + '@snowplow/tracker-core': link:../../libraries/tracker-core + tslib: 2.3.1 + devDependencies: + '@ampproject/rollup-plugin-closure-compiler': 0.27.0_rollup@2.70.1 + '@rollup/plugin-commonjs': 21.0.2_rollup@2.70.1 + '@rollup/plugin-node-resolve': 13.1.3_rollup@2.70.1 + '@types/jest': 27.4.1 + '@types/jsdom': 16.2.14 + '@typescript-eslint/eslint-plugin': 5.15.0_f2c49ce7d0e93ebcfdb4b7d25b131b28 + '@typescript-eslint/parser': 5.15.0_eslint@8.11.0+typescript@4.6.2 + eslint: 8.11.0 + jest: 27.5.1 + jest-environment-jsdom: 27.5.1 + jest-environment-jsdom-global: 3.0.0_jest-environment-jsdom@27.5.1 + jest-standard-reporter: 2.0.0 + rollup: 2.70.1 + rollup-plugin-cleanup: 3.2.1_rollup@2.70.1 + rollup-plugin-license: 2.6.1_rollup@2.70.1 + rollup-plugin-terser: 7.0.2_rollup@2.70.1 + rollup-plugin-ts: 2.0.5_rollup@2.70.1+typescript@4.6.2 + ts-jest: 27.1.3_60149d457e34ffba7d4e845dde6a1263 + typescript: 4.6.2 + ../../plugins/browser-plugin-form-tracking: specifiers: '@ampproject/rollup-plugin-closure-compiler': ~0.27.0 diff --git a/common/config/rush/repo-state.json b/common/config/rush/repo-state.json index 3c7f1fddd..fc675a41f 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": "a89befd25d9043396ee1a3dda2def22a80582f46", + "pnpmShrinkwrapHash": "f6c3c1da60e38fc2db1587dd57a4b39e2ff21caf", "preferredVersionsHash": "bf21a9e8fbc5a3846fb05b4fa0859e0917b2202f" } diff --git a/plugins/browser-plugin-focalmeter/LICENSE b/plugins/browser-plugin-focalmeter/LICENSE new file mode 100644 index 000000000..76f1946ea --- /dev/null +++ b/plugins/browser-plugin-focalmeter/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-focalmeter/README.md b/plugins/browser-plugin-focalmeter/README.md new file mode 100644 index 000000000..668656a21 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/README.md @@ -0,0 +1,56 @@ +# Snowplow FocalMeter Integration + +[![npm version][npm-image]][npm-url] +[![License][license-image]](LICENSE) + +Browser Plugin to be used with `@snowplow/browser-tracker`. + +Adds integration with the Kantar FocalMeter to your Snowplow tracking. +The plugin sends requests with the domain user ID to a Kantar endpoint used with the FocalMeter system. +A request is made when the first event with a new user ID is tracked. + +## Maintainer quick start + +Part of the Snowplow JavaScript Tracker monorepo. +Build with [Node.js](https://nodejs.org/en/) (14 or 16) 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-focalmeter +``` + +## Usage + +Initialize your tracker with the FocalMeterPlugin: + +```js +import { newTracker } from '@snowplow/browser-tracker'; +import { FocalMeterPlugin } from '@snowplow/browser-plugin-focalmeter'; + +newTracker('sp1', '{{collector}}', { plugins: [ FocalMeterPlugin() ] }); // Also stores reference at module level +``` + +## Copyright and license + +Licensed and distributed under the [BSD 3-Clause License](LICENSE) ([An OSI Approved License][osi]). + +Copyright (c) 2022 Snowplow Analytics Ltd, 2010 Anthon Pang. + +All rights reserved. + +[npm-url]: https://www.npmjs.com/package/@snowplow/browser-plugin-focalmeter +[npm-image]: https://img.shields.io/npm/v/@snowplow/browser-plugin-focalmeter +[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-focalmeter diff --git a/plugins/browser-plugin-focalmeter/jest.config.js b/plugins/browser-plugin-focalmeter/jest.config.js new file mode 100644 index 000000000..bd3ea4e2a --- /dev/null +++ b/plugins/browser-plugin-focalmeter/jest.config.js @@ -0,0 +1,5 @@ +module.exports = { + preset: 'ts-jest', + reporters: ['jest-standard-reporter'], + testEnvironment: 'jest-environment-jsdom-global', +}; diff --git a/plugins/browser-plugin-focalmeter/package.json b/plugins/browser-plugin-focalmeter/package.json new file mode 100644 index 000000000..26a51dbb3 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/package.json @@ -0,0 +1,53 @@ +{ + "name": "@snowplow/browser-plugin-focalmeter", + "version": "3.7.0", + "description": "Kantar FocalMeter integration for Snowplow", + "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": "Matus Tomlein", + "sideEffects": false, + "main": "./dist/index.umd.js", + "module": "./dist/index.module.js", + "types": "./dist/index.module.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c --silent --failAfterWarnings", + "test": "jest" + }, + "dependencies": { + "@snowplow/browser-tracker-core": "workspace:*", + "@snowplow/tracker-core": "workspace:*", + "tslib": "^2.3.1" + }, + "devDependencies": { + "@ampproject/rollup-plugin-closure-compiler": "~0.27.0", + "@rollup/plugin-commonjs": "~21.0.2", + "@rollup/plugin-node-resolve": "~13.1.3", + "@types/jest": "~27.4.1", + "@types/jsdom": "~16.2.14", + "@typescript-eslint/eslint-plugin": "~5.15.0", + "@typescript-eslint/parser": "~5.15.0", + "eslint": "~8.11.0", + "jest": "~27.5.1", + "jest-environment-jsdom": "~27.5.1", + "jest-environment-jsdom-global": "~3.0.0", + "jest-standard-reporter": "~2.0.0", + "rollup": "~2.70.1", + "rollup-plugin-cleanup": "~3.2.1", + "rollup-plugin-license": "~2.6.1", + "rollup-plugin-terser": "~7.0.2", + "rollup-plugin-ts": "~2.0.5", + "ts-jest": "~27.1.3", + "typescript": "~4.6.2" + }, + "peerDependencies": { + "@snowplow/browser-tracker": "~3.7.0" + } +} diff --git a/plugins/browser-plugin-focalmeter/rollup.config.js b/plugins/browser-plugin-focalmeter/rollup.config.js new file mode 100644 index 000000000..1c024021b --- /dev/null +++ b/plugins/browser-plugin-focalmeter/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 = 'snowplowFocalMeter'; + +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-focalmeter/src/index.ts b/plugins/browser-plugin-focalmeter/src/index.ts new file mode 100644 index 000000000..804e36772 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/src/index.ts @@ -0,0 +1,91 @@ +/* + * 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 { BrowserPlugin } from '@snowplow/browser-tracker-core'; +import { Logger, LOG_LEVEL, Payload } from '@snowplow/tracker-core'; + +/** + * The FocalMeter Plugin + * + * The plugin sends requests with the domain user ID to a Kantar endpoint used with the FocalMeter system. + * A request is made when the first event with a new user ID is tracked. + * + * @param kantarEndpoint URL of the Kantar endpoint to send the requests to (including protocol) + * @param logLevel Log message level + * @returns The plugin + */ +export function FocalMeterPlugin(kantarEndpoint: string, logLevel: LOG_LEVEL = LOG_LEVEL.debug): BrowserPlugin { + let LOG: Logger; + let lastUserId: string | undefined; + + return { + logger: (logger) => { + LOG = logger; + logger.setLogLevel(logLevel); + }, + afterTrack: (payload: Payload) => { + let newUserId = payload['duid'] as string; + + if (newUserId && newUserId != lastUserId) { + lastUserId = newUserId; + sendRequest(kantarEndpoint, newUserId, LOG); + } + }, + }; +} + +function sendRequest(url: string, userId: string, LOG: Logger) { + let query = { + vendor: 'snowplow', + cs_fpid: userId, + c12: 'not_set', + }; + url += + '?' + + Object.keys(query) + .map((key) => key + '=' + (query as any)[key]) + .join('&'); + + const xhr = new XMLHttpRequest(); + xhr.open('GET', url); + xhr.timeout = 5000; + + xhr.onreadystatechange = function () { + if (xhr.readyState === 4 && xhr.status >= 200) { + if (xhr.status < 300) { + LOG.debug(`ID sent to Kantar: ${userId}`); + } else { + LOG.error(`Kantar request failed: ${xhr.status}: ${xhr.statusText}`); + } + } + }; + + xhr.send(); +} diff --git a/plugins/browser-plugin-focalmeter/test/request.test.ts b/plugins/browser-plugin-focalmeter/test/request.test.ts new file mode 100644 index 000000000..87b3cf030 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/test/request.test.ts @@ -0,0 +1,121 @@ +/* + * 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 { addTracker, BrowserPlugin, SharedState } from '@snowplow/browser-tracker-core'; +import { FocalMeterPlugin } from '../src'; + +describe('AdTrackingPlugin', () => { + // Mock XHR network requests + let xhrMock: Partial; + let xhrOpenMock: jest.Mock; + let queuedStateChangeCallbacks: any[] = []; + let domain = 'https://kantar.com'; + + beforeEach(() => { + xhrOpenMock = jest.fn(); + xhrMock = { + open: xhrOpenMock, + send: jest.fn(), + setRequestHeader: jest.fn(), + withCredentials: true, + status: 200, + response: '', + readyState: 4, + }; + + Object.defineProperty(xhrMock, 'onreadystatechange', { + set: (val) => { + queuedStateChangeCallbacks.push(val); + }, + }); + + jest.spyOn(window, 'XMLHttpRequest').mockImplementation(() => xhrMock as XMLHttpRequest); + }); + + it('makes a request to Kantar endpoint with user ID', async () => { + let tracker = createTrackerWithPlugin(FocalMeterPlugin(domain)); + + tracker?.trackPageView(); + let userId = tracker?.getDomainUserId(); + + await checkMock(() => { + expect(xhrOpenMock).toHaveBeenCalledTimes(2); + expect(xhrOpenMock).toHaveBeenLastCalledWith('GET', `${domain}?vendor=snowplow&cs_fpid=${userId}&c12=not_set`); + }); + }); + + it('makes a request to Kantar endpoint when user ID changes', async () => { + let tracker = createTrackerWithPlugin(FocalMeterPlugin(domain)); + + // Doesn't make a request if anonymous tracking + tracker?.enableAnonymousTracking(); + tracker?.trackPageView(); + await checkMock(() => { + expect(xhrOpenMock).toHaveBeenCalledTimes(1); + }); + + // Makes a request when disabling anonymous tracking + tracker?.disableAnonymousTracking(); + tracker?.trackPageView(); + let userId = tracker?.getDomainUserId(); + await checkMock(() => { + expect(xhrOpenMock).toHaveBeenCalledTimes(2); + expect(xhrOpenMock).toHaveBeenLastCalledWith('GET', `${domain}?vendor=snowplow&cs_fpid=${userId}&c12=not_set`); + }); + + // Doesn't make another request since user ID didn't change + tracker?.trackPageView(); + await checkMock(() => { + expect(xhrOpenMock).toHaveBeenCalledTimes(1); + }); + }); + + function createTrackerWithPlugin(plugin: BrowserPlugin) { + const state = new SharedState(); + let id = 'sp-' + Math.random(); + return addTracker(id, id, 'js-3.0.0', '', state, { + stateStorageStrategy: 'cookie', + encodeBase64: false, + plugins: [plugin], + }); + } + + function checkMock(callback: () => void) { + return new Promise((resolve) => { + setTimeout(() => { + callback(); + queuedStateChangeCallbacks.forEach((callback) => callback()); + queuedStateChangeCallbacks = []; + xhrOpenMock.mockReset(); + resolve(true); + }, 100); + }); + } +}); diff --git a/plugins/browser-plugin-focalmeter/tsconfig.json b/plugins/browser-plugin-focalmeter/tsconfig.json new file mode 100644 index 000000000..4082f16a5 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "../../tsconfig.json" +} diff --git a/rush.json b/rush.json index 9dccb37aa..2959be589 100644 --- a/rush.json +++ b/rush.json @@ -541,6 +541,12 @@ "projectFolder": "plugins/browser-plugin-enhanced-consent", "reviewCategory": "plugins", "versionPolicyName": "tracker" + }, + { + "packageName": "@snowplow/browser-plugin-focalmeter", + "projectFolder": "plugins/browser-plugin-focalmeter", + "reviewCategory": "plugins", + "versionPolicyName": "tracker" } ] } From d35a60fb312339dc8040fe107bc8dbc264b790a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Thu, 22 Dec 2022 14:05:20 +0100 Subject: [PATCH 2/5] Add rush change description --- .../issue-focalmeter_2022-12-22-13-04.json | 10 ++++++++++ plugins/browser-plugin-focalmeter/CHANGELOG.json | 5 +++++ 2 files changed, 15 insertions(+) create mode 100644 common/changes/@snowplow/browser-plugin-focalmeter/issue-focalmeter_2022-12-22-13-04.json create mode 100644 plugins/browser-plugin-focalmeter/CHANGELOG.json diff --git a/common/changes/@snowplow/browser-plugin-focalmeter/issue-focalmeter_2022-12-22-13-04.json b/common/changes/@snowplow/browser-plugin-focalmeter/issue-focalmeter_2022-12-22-13-04.json new file mode 100644 index 000000000..325547edf --- /dev/null +++ b/common/changes/@snowplow/browser-plugin-focalmeter/issue-focalmeter_2022-12-22-13-04.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-plugin-focalmeter", + "comment": "Add plugin to send requests with user ID to a Kantar FocalMeter endpoint (#1133)", + "type": "none" + } + ], + "packageName": "@snowplow/browser-plugin-focalmeter" +} \ No newline at end of file diff --git a/plugins/browser-plugin-focalmeter/CHANGELOG.json b/plugins/browser-plugin-focalmeter/CHANGELOG.json new file mode 100644 index 000000000..7f74dc674 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/CHANGELOG.json @@ -0,0 +1,5 @@ +{ + "name": "@snowplow/browser-plugin-focalmeter", + "entries": [ + ] +} From 058dc08a002503395e9b773d18c159ad88c6709e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Thu, 22 Dec 2022 15:33:18 +0100 Subject: [PATCH 3/5] Add support for multiple trackers and local storage --- .../browser-plugin-focalmeter/src/index.ts | 76 ++++++++++++++----- .../test/request.test.ts | 23 +++++- 2 files changed, 79 insertions(+), 20 deletions(-) diff --git a/plugins/browser-plugin-focalmeter/src/index.ts b/plugins/browser-plugin-focalmeter/src/index.ts index 804e36772..8077a5a3f 100644 --- a/plugins/browser-plugin-focalmeter/src/index.ts +++ b/plugins/browser-plugin-focalmeter/src/index.ts @@ -28,7 +28,13 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { BrowserPlugin } from '@snowplow/browser-tracker-core'; +import { + attemptGetLocalStorage, + BrowserPlugin, + hasLocalStorage, + BrowserTracker, + attemptWriteLocalStorage, +} from '@snowplow/browser-tracker-core'; import { Logger, LOG_LEVEL, Payload } from '@snowplow/tracker-core'; /** @@ -38,48 +44,67 @@ import { Logger, LOG_LEVEL, Payload } from '@snowplow/tracker-core'; * A request is made when the first event with a new user ID is tracked. * * @param kantarEndpoint URL of the Kantar endpoint to send the requests to (including protocol) + * @param useLocalStorage Whether to store information about the last submitted user ID in local storage to prevent sending it again on next load * @param logLevel Log message level * @returns The plugin */ -export function FocalMeterPlugin(kantarEndpoint: string, logLevel: LOG_LEVEL = LOG_LEVEL.debug): BrowserPlugin { +export function FocalMeterPlugin( + kantarEndpoint: string, + useLocalStorage: boolean = false, + logLevel: LOG_LEVEL = LOG_LEVEL.debug +): BrowserPlugin { let LOG: Logger; - let lastUserId: string | undefined; + let lastUserIdsByNamespace: Record = {}; return { logger: (logger) => { LOG = logger; logger.setLogLevel(logLevel); }, + + activateBrowserPlugin: (tracker: BrowserTracker) => { + if (useLocalStorage && hasLocalStorage()) { + let key = getLocalStorageKey(tracker.namespace); + let userId = attemptGetLocalStorage(key); + + if (userId) { + lastUserIdsByNamespace[tracker.namespace] = userId; + } + } + }, + afterTrack: (payload: Payload) => { let newUserId = payload['duid'] as string; + let trackerNamespace = payload['tna'] as string; + + if (newUserId && newUserId != lastUserIdsByNamespace[trackerNamespace]) { + lastUserIdsByNamespace[trackerNamespace] = newUserId; - if (newUserId && newUserId != lastUserId) { - lastUserId = newUserId; - sendRequest(kantarEndpoint, newUserId, LOG); + sendRequest(kantarEndpoint, newUserId, LOG, () => { + // only write in local storage if the request succeeded + if (useLocalStorage && hasLocalStorage()) { + let key = getLocalStorageKey(trackerNamespace); + attemptWriteLocalStorage(key, newUserId); + } + }); } }, }; } -function sendRequest(url: string, userId: string, LOG: Logger) { - let query = { - vendor: 'snowplow', - cs_fpid: userId, - c12: 'not_set', - }; - url += - '?' + - Object.keys(query) - .map((key) => key + '=' + (query as any)[key]) - .join('&'); +function getLocalStorageKey(trackerNamespace: string) { + return `sp-fclmtr-${trackerNamespace}`; +} +function sendRequest(url: string, userId: string, LOG: Logger, successCallback: () => void) { const xhr = new XMLHttpRequest(); - xhr.open('GET', url); + xhr.open('GET', getKantarURL(url, userId)); xhr.timeout = 5000; xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status >= 200) { if (xhr.status < 300) { + successCallback(); LOG.debug(`ID sent to Kantar: ${userId}`); } else { LOG.error(`Kantar request failed: ${xhr.status}: ${xhr.statusText}`); @@ -89,3 +114,18 @@ function sendRequest(url: string, userId: string, LOG: Logger) { xhr.send(); } + +function getKantarURL(url: string, userId: string) { + let query: Record = { + vendor: 'snowplow', + cs_fpid: userId, + c12: 'not_set', + }; + return ( + url + + '?' + + Object.keys(query) + .map((key) => key + '=' + query[key]) + .join('&') + ); +} diff --git a/plugins/browser-plugin-focalmeter/test/request.test.ts b/plugins/browser-plugin-focalmeter/test/request.test.ts index 87b3cf030..70a075245 100644 --- a/plugins/browser-plugin-focalmeter/test/request.test.ts +++ b/plugins/browser-plugin-focalmeter/test/request.test.ts @@ -97,9 +97,28 @@ describe('AdTrackingPlugin', () => { }); }); - function createTrackerWithPlugin(plugin: BrowserPlugin) { + it('can work with multiple trackers', async () => { + let plugin = FocalMeterPlugin(domain); + let tracker1 = createTrackerWithPlugin(plugin); + let tracker2 = createTrackerWithPlugin(plugin); + + // Makes requests for both trackers + tracker1?.trackPageView(); + await checkMock(() => expect(xhrOpenMock).toHaveBeenCalledTimes(2)); + tracker2?.trackPageView(); + await checkMock(() => expect(xhrOpenMock).toHaveBeenCalledTimes(2)); + + // Doesn't make any more requests for the trackers + tracker1?.trackPageView(); + await checkMock(() => expect(xhrOpenMock).toHaveBeenCalledTimes(1)); + tracker2?.trackPageView(); + await checkMock(() => expect(xhrOpenMock).toHaveBeenCalledTimes(1)); + }); + + function createTrackerWithPlugin(plugin: BrowserPlugin, id: string | undefined = undefined) { const state = new SharedState(); - let id = 'sp-' + Math.random(); + id ??= 'sp-' + Math.random(); + return addTracker(id, id, 'js-3.0.0', '', state, { stateStorageStrategy: 'cookie', encodeBase64: false, From 18434f6a5e51f871e94079e48e5740bf47d2bc0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Thu, 5 Jan 2023 14:18:18 +0100 Subject: [PATCH 4/5] Add FocalMeterConfiguration and extract enableFocalMeterIntegration method --- .../browser-plugin-focalmeter/src/index.ts | 87 ++++++++++++------- .../test/request.test.ts | 26 ++++-- 2 files changed, 73 insertions(+), 40 deletions(-) diff --git a/plugins/browser-plugin-focalmeter/src/index.ts b/plugins/browser-plugin-focalmeter/src/index.ts index 8077a5a3f..72841b010 100644 --- a/plugins/browser-plugin-focalmeter/src/index.ts +++ b/plugins/browser-plugin-focalmeter/src/index.ts @@ -35,7 +35,18 @@ import { BrowserTracker, attemptWriteLocalStorage, } from '@snowplow/browser-tracker-core'; -import { Logger, LOG_LEVEL, Payload } from '@snowplow/tracker-core'; +import { Logger, Payload } from '@snowplow/tracker-core'; + +/** FocalMeter plugin configuration */ +export interface FocalMeterConfiguration { + /** URL of the Kantar endpoint to send the requests to (including protocol) */ + kantarEndpoint: string; + /** Whether to store information about the last submitted user ID in local storage to prevent sending it again on next load (defaults not to use local storage) */ + useLocalStorage?: boolean; +} + +const _trackers: Record = {}; +const _configurations: Record = {}; /** * The FocalMeter Plugin @@ -43,47 +54,44 @@ import { Logger, LOG_LEVEL, Payload } from '@snowplow/tracker-core'; * The plugin sends requests with the domain user ID to a Kantar endpoint used with the FocalMeter system. * A request is made when the first event with a new user ID is tracked. * - * @param kantarEndpoint URL of the Kantar endpoint to send the requests to (including protocol) - * @param useLocalStorage Whether to store information about the last submitted user ID in local storage to prevent sending it again on next load - * @param logLevel Log message level - * @returns The plugin + * Call `enableFocalMeterIntegration()` to enable the integration with given configuration. */ -export function FocalMeterPlugin( - kantarEndpoint: string, - useLocalStorage: boolean = false, - logLevel: LOG_LEVEL = LOG_LEVEL.debug -): BrowserPlugin { +export function FocalMeterPlugin(): BrowserPlugin { let LOG: Logger; - let lastUserIdsByNamespace: Record = {}; + let lastUserId: string | undefined | null; + let trackerId: string; return { - logger: (logger) => { - LOG = logger; - logger.setLogLevel(logLevel); - }, - activateBrowserPlugin: (tracker: BrowserTracker) => { - if (useLocalStorage && hasLocalStorage()) { - let key = getLocalStorageKey(tracker.namespace); - let userId = attemptGetLocalStorage(key); + trackerId = tracker.id; + _trackers[tracker.id] = tracker; + }, - if (userId) { - lastUserIdsByNamespace[tracker.namespace] = userId; - } - } + logger: (logger: Logger) => { + LOG = logger; }, afterTrack: (payload: Payload) => { + if (!_configurations[trackerId]) { + LOG.error('FocalMeter integration not enabled'); + return; + } + let newUserId = payload['duid'] as string; - let trackerNamespace = payload['tna'] as string; + let { kantarEndpoint, useLocalStorage } = _configurations[trackerId]; + + if (!lastUserId && useLocalStorage && hasLocalStorage()) { + let key = getLocalStorageKey(trackerId); + lastUserId = attemptGetLocalStorage(key); + } - if (newUserId && newUserId != lastUserIdsByNamespace[trackerNamespace]) { - lastUserIdsByNamespace[trackerNamespace] = newUserId; + if (newUserId && newUserId != lastUserId) { + lastUserId = newUserId; sendRequest(kantarEndpoint, newUserId, LOG, () => { // only write in local storage if the request succeeded if (useLocalStorage && hasLocalStorage()) { - let key = getLocalStorageKey(trackerNamespace); + let key = getLocalStorageKey(trackerId); attemptWriteLocalStorage(key, newUserId); } }); @@ -92,11 +100,28 @@ export function FocalMeterPlugin( }; } -function getLocalStorageKey(trackerNamespace: string) { - return `sp-fclmtr-${trackerNamespace}`; +/** + * Enables the integration with Kantar FocalMeter. + * + * @param configuration - Configuration with the URL endpoint to send requests to + * @param trackers - The tracker identifiers which should have the context enabled + */ +export function enableFocalMeterIntegration( + configuration: FocalMeterConfiguration, + trackers: Array = Object.keys(_trackers) +): void { + for (const id of trackers) { + if (_trackers[id]) { + _configurations[id] = configuration; + } + } +} + +function getLocalStorageKey(trackerId: string): string { + return `sp-fclmtr-${trackerId}`; } -function sendRequest(url: string, userId: string, LOG: Logger, successCallback: () => void) { +function sendRequest(url: string, userId: string, LOG: Logger, successCallback: () => void): void { const xhr = new XMLHttpRequest(); xhr.open('GET', getKantarURL(url, userId)); xhr.timeout = 5000; @@ -115,7 +140,7 @@ function sendRequest(url: string, userId: string, LOG: Logger, successCallback: xhr.send(); } -function getKantarURL(url: string, userId: string) { +function getKantarURL(url: string, userId: string): string { let query: Record = { vendor: 'snowplow', cs_fpid: userId, diff --git a/plugins/browser-plugin-focalmeter/test/request.test.ts b/plugins/browser-plugin-focalmeter/test/request.test.ts index 70a075245..6c609e67b 100644 --- a/plugins/browser-plugin-focalmeter/test/request.test.ts +++ b/plugins/browser-plugin-focalmeter/test/request.test.ts @@ -28,8 +28,8 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { addTracker, BrowserPlugin, SharedState } from '@snowplow/browser-tracker-core'; -import { FocalMeterPlugin } from '../src'; +import { addTracker, SharedState } from '@snowplow/browser-tracker-core'; +import { enableFocalMeterIntegration, FocalMeterPlugin } from '../src'; describe('AdTrackingPlugin', () => { // Mock XHR network requests @@ -60,7 +60,8 @@ describe('AdTrackingPlugin', () => { }); it('makes a request to Kantar endpoint with user ID', async () => { - let tracker = createTrackerWithPlugin(FocalMeterPlugin(domain)); + let tracker = createTrackerWithPlugin(); + enableFocalMeterIntegration({ kantarEndpoint: domain }); tracker?.trackPageView(); let userId = tracker?.getDomainUserId(); @@ -72,7 +73,8 @@ describe('AdTrackingPlugin', () => { }); it('makes a request to Kantar endpoint when user ID changes', async () => { - let tracker = createTrackerWithPlugin(FocalMeterPlugin(domain)); + let tracker = createTrackerWithPlugin(); + enableFocalMeterIntegration({ kantarEndpoint: domain }); // Doesn't make a request if anonymous tracking tracker?.enableAnonymousTracking(); @@ -98,9 +100,15 @@ describe('AdTrackingPlugin', () => { }); it('can work with multiple trackers', async () => { - let plugin = FocalMeterPlugin(domain); - let tracker1 = createTrackerWithPlugin(plugin); - let tracker2 = createTrackerWithPlugin(plugin); + let tracker1 = createTrackerWithPlugin(); + let tracker2 = createTrackerWithPlugin(); + + enableFocalMeterIntegration( + { + kantarEndpoint: domain, + }, + [tracker1!.namespace, tracker2!.namespace] + ); // Makes requests for both trackers tracker1?.trackPageView(); @@ -115,14 +123,14 @@ describe('AdTrackingPlugin', () => { await checkMock(() => expect(xhrOpenMock).toHaveBeenCalledTimes(1)); }); - function createTrackerWithPlugin(plugin: BrowserPlugin, id: string | undefined = undefined) { + function createTrackerWithPlugin(id: string | undefined = undefined) { const state = new SharedState(); id ??= 'sp-' + Math.random(); return addTracker(id, id, 'js-3.0.0', '', state, { stateStorageStrategy: 'cookie', encodeBase64: false, - plugins: [plugin], + plugins: [FocalMeterPlugin()], }); } From 3ba9203e07fe39804e82d07efb0e487e22634e8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Mon, 9 Jan 2023 16:48:45 +0100 Subject: [PATCH 5/5] Update focalmeter plugin version --- plugins/browser-plugin-focalmeter/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/browser-plugin-focalmeter/package.json b/plugins/browser-plugin-focalmeter/package.json index 26a51dbb3..67153b30a 100644 --- a/plugins/browser-plugin-focalmeter/package.json +++ b/plugins/browser-plugin-focalmeter/package.json @@ -1,6 +1,6 @@ { "name": "@snowplow/browser-plugin-focalmeter", - "version": "3.7.0", + "version": "3.8.0", "description": "Kantar FocalMeter integration for Snowplow", "homepage": "http://bit.ly/sp-js", "bugs": "https://github.com/snowplow/snowplow-javascript-tracker/issues", @@ -48,6 +48,6 @@ "typescript": "~4.6.2" }, "peerDependencies": { - "@snowplow/browser-tracker": "~3.7.0" + "@snowplow/browser-tracker": "~3.8.0" } }