diff --git a/.github/workflows/publish_prerelease.yml b/.github/workflows/publish_prerelease.yml index 116cd9b5d..2f7491620 100644 --- a/.github/workflows/publish_prerelease.yml +++ b/.github/workflows/publish_prerelease.yml @@ -61,7 +61,7 @@ jobs: run: node common/scripts/install-run-rush.js test --verbose - name: Install API Extractor & Documenter - run: npm install -g @microsoft/api-extractor@^7.18.4 @microsoft/api-documenter@^7.13.34 + run: npm install -g @microsoft/api-extractor@7.18.4 @microsoft/api-documenter@7.13.34 - name: Create API documentation for @snowplow/browser-tracker working-directory: ./trackers/browser-tracker 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/common/changes/@snowplow/browser-tracker-core/issue-1124-client_session_2022-12-23-13-19.json b/common/changes/@snowplow/browser-tracker-core/issue-1124-client_session_2022-12-23-13-19.json new file mode 100644 index 000000000..68088f330 --- /dev/null +++ b/common/changes/@snowplow/browser-tracker-core/issue-1124-client_session_2022-12-23-13-19.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@snowplow/browser-tracker-core", + "comment": "Add client_session context entity if anonymous tracking with session tracking is enabled (#1124)", + "type": "none" + } + ], + "packageName": "@snowplow/browser-tracker-core" +} \ 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 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/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index 9ded67980..1b713a40f 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -33,7 +33,7 @@ * in the current branch. When bumping versions, Rush uses this to determine the next version. * (The "version" field in package.json is NOT considered.) */ - "version": "3.8.0", + "version": "3.9.0-beta.0", /** * (Required) The type of bump that will be performed when publishing the next release. @@ -42,6 +42,6 @@ * * Valid values are: "prerelease", "release", "minor", "patch", "major" */ - "nextBump": "minor" + "nextBump": "prerelease" } ] diff --git a/libraries/browser-tracker-core/src/tracker/id_cookie.ts b/libraries/browser-tracker-core/src/tracker/id_cookie.ts index 6277d8f50..c350f8f5d 100644 --- a/libraries/browser-tracker-core/src/tracker/id_cookie.ts +++ b/libraries/browser-tracker-core/src/tracker/id_cookie.ts @@ -300,14 +300,20 @@ export function serializeIdCookie(idCookie: ParsedIdCookie) { * @param configStateStorageStrategy Cookie storage strategy * @returns Client session context entity */ -export function clientSessionFromIdCookie(idCookie: ParsedIdCookie, configStateStorageStrategy: string) { +export function clientSessionFromIdCookie( + idCookie: ParsedIdCookie, + configStateStorageStrategy: string, + configAnonymousTracking: boolean +) { const firstEventTsInMs = idCookie[firstEventTsInMsIndex]; const clientSession: ClientSession = { - userId: idCookie[domainUserIdIndex], + userId: configAnonymousTracking + ? '00000000-0000-0000-0000-000000000000' // TODO: use uuid.NIL when we upgrade to uuid v8.3 + : idCookie[domainUserIdIndex], sessionId: idCookie[sessionIdIndex], eventIndex: idCookie[eventIndexIndex], sessionIndex: idCookie[visitCountIndex], - previousSessionId: idCookie[previousSessionIdIndex] || null, + previousSessionId: configAnonymousTracking ? null : idCookie[previousSessionIdIndex] || null, storageMechanism: configStateStorageStrategy == 'localStorage' ? 'LOCAL_STORAGE' : 'COOKIE_1', firstEventId: idCookie[firstEventIdIndex] || null, firstEventTimestamp: firstEventTsInMs ? new Date(firstEventTsInMs).toISOString() : null, diff --git a/libraries/browser-tracker-core/src/tracker/index.ts b/libraries/browser-tracker-core/src/tracker/index.ts index b2bcb4362..1952485fd 100755 --- a/libraries/browser-tracker-core/src/tracker/index.ts +++ b/libraries/browser-tracker-core/src/tracker/index.ts @@ -787,8 +787,11 @@ export function Tracker( // Add the page URL last as it may take us over the IE limit (and we don't always need it) payloadBuilder.add('url', purify(configCustomUrl || locationHrefAlias)); - if (configSessionContext && !configAnonymousSessionTracking && !configAnonymousTracking) { - addSessionContextToPayload(payloadBuilder, clientSessionFromIdCookie(idCookie, configStateStorageStrategy)); + if (configSessionContext && (!configAnonymousTracking || configAnonymousSessionTracking)) { + addSessionContextToPayload( + payloadBuilder, + clientSessionFromIdCookie(idCookie, configStateStorageStrategy, configAnonymousTracking) + ); } // Update cookies diff --git a/libraries/browser-tracker-core/test/id_cookie.test.ts b/libraries/browser-tracker-core/test/id_cookie.test.ts index cca0fcd09..aadbea178 100644 --- a/libraries/browser-tracker-core/test/id_cookie.test.ts +++ b/libraries/browser-tracker-core/test/id_cookie.test.ts @@ -262,7 +262,7 @@ describe('serializeIdCookie', () => { describe('clientSessionFromIdCookie', () => { it('Correctly fills out the properties', () => { let idCookie = parseIdCookie('def.1653632272.10.1653632282.1653632262.ses.previous.fid.1653638673483.9', '', '', 0); - let clientSession = clientSessionFromIdCookie(idCookie, 'cookieAndLocalStorage'); + let clientSession = clientSessionFromIdCookie(idCookie, 'cookieAndLocalStorage', false); expect(clientSession.userId).toBe('def'); expect(clientSession.sessionId).toBe('ses'); @@ -273,4 +273,18 @@ describe('clientSessionFromIdCookie', () => { expect(clientSession.firstEventId).toBe('fid'); expect(clientSession.firstEventTimestamp).toBe('2022-05-27T08:04:33.483Z'); }); + + it('Anonymises userId and previousSessionId when anonymous tracking', () => { + let idCookie = parseIdCookie('def.1653632272.10.1653632282.1653632262.ses.previous.fid.1653638673483.9', '', '', 0); + let clientSession = clientSessionFromIdCookie(idCookie, 'cookieAndLocalStorage', true); + + expect(clientSession.userId).toBe('00000000-0000-0000-0000-000000000000'); + expect(clientSession.sessionId).toBe('ses'); + expect(clientSession.previousSessionId).toBeNull; + expect(clientSession.eventIndex).toBe(9); + expect(clientSession.sessionIndex).toBe(10); + expect(clientSession.storageMechanism).toBe('COOKIE_1'); + expect(clientSession.firstEventId).toBe('fid'); + expect(clientSession.firstEventTimestamp).toBe('2022-05-27T08:04:33.483Z'); + }); }); diff --git a/libraries/browser-tracker-core/test/tracker/session_data.test.ts b/libraries/browser-tracker-core/test/tracker/session_data.test.ts index d000b30a2..c64a991fd 100644 --- a/libraries/browser-tracker-core/test/tracker/session_data.test.ts +++ b/libraries/browser-tracker-core/test/tracker/session_data.test.ts @@ -28,6 +28,7 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +import { TrackerConfiguration } from '../../dist/index.module'; import { addTracker, SharedState } from '../../src'; import { createTestIdCookie, createTestSessionIdCookie } from '../helpers'; @@ -54,7 +55,7 @@ describe('Tracker API: ', () => { }); it('Sets initial domain session index on first session', () => { - const tracker = addTracker('sp1', 'sp1', '', '', new SharedState()); + const tracker = createTracker(); expect(tracker?.getDomainSessionIndex()).toEqual(1); }); @@ -62,7 +63,7 @@ describe('Tracker API: ', () => { it('Sets correct domain session index on new session', () => { const initialSessionIndex = 1; document.cookie = createTestIdCookie({ visitCount: initialSessionIndex }); - const tracker = addTracker('sp2', 'sp2', '', '', new SharedState()); + const tracker = createTracker(); expect(tracker?.getDomainSessionIndex()).toEqual(initialSessionIndex + 1); }); @@ -70,7 +71,7 @@ describe('Tracker API: ', () => { it('Sets correct domain session index on existing session', () => { const initialSessionIndex = 2; document.cookie = createTestIdCookie({ visitCount: initialSessionIndex }) + ' ' + createTestSessionIdCookie(); - const tracker = addTracker('sp3', 'sp3', '', '', new SharedState()); + const tracker = createTracker(); expect(tracker?.getDomainSessionIndex()).toEqual(initialSessionIndex); }); @@ -78,7 +79,7 @@ describe('Tracker API: ', () => { it('Sets correct domain session index (1) after clearUserData() on existing session', () => { const initialSessionIndex = 2; document.cookie = createTestIdCookie({ visitCount: initialSessionIndex }) + ' ' + createTestSessionIdCookie(); - const tracker = addTracker('sp4', 'sp4', '', '', new SharedState()); + const tracker = createTracker(); expect(tracker?.getDomainSessionIndex()).toEqual(initialSessionIndex); tracker?.clearUserData(); @@ -86,13 +87,13 @@ describe('Tracker API: ', () => { }); it('Sets correct domain session index anonymous track', () => { - const tracker = addTracker('sp5', 'sp5', '', '', new SharedState(), { anonymousTracking: true }); + const tracker = createTracker({ anonymousTracking: true }); expect(tracker?.getDomainSessionIndex()).toEqual(1); }); it('Retains correct domain session index on opt-out cookie present', () => { const optOutCookieName = 'optOut'; - const tracker = addTracker('sp6', 'sp6', '', '', new SharedState()); + const tracker = createTracker(); tracker?.setOptOutCookie(optOutCookieName); document.cookie = `${optOutCookieName}=1;`; @@ -102,10 +103,73 @@ describe('Tracker API: ', () => { it('Sets correct domain session index after session expiration', () => { // Session timeout is in seconds - const tracker = addTracker('sp7', 'sp7', '', '', new SharedState(), { sessionCookieTimeout: 1 }); + const tracker = createTracker({ sessionCookieTimeout: 1 }); // Advance timer by more than one second jest.advanceTimersByTime(1001); tracker?.trackPageView({ title: 'my page' }); expect(tracker?.getDomainSessionIndex()).toEqual(2); }); + + it('Adds the client session context entity when enabled', (done) => { + const tracker = createTracker({ + contexts: { session: true }, + encodeBase64: false, + plugins: [ + { + afterTrack: (payload) => { + let context = payload.co as string; + expect(context).toContain('client_session'); + done(); + }, + }, + ], + }); + + tracker?.trackPageView(); + }); + + it('Adds the client session context entity when anonymous session tracking', (done) => { + const tracker = createTracker({ + contexts: { session: true }, + encodeBase64: false, + anonymousTracking: { withSessionTracking: true }, + plugins: [ + { + afterTrack: (payload) => { + let context = payload.co as string; + expect(context).toContain('client_session'); + expect(context).toContain('"userId":"00000000-0000-0000-0000-000000000000"'); + expect(context).toContain('"previousSessionId":null'); + done(); + }, + }, + ], + }); + + tracker?.trackPageView(); + }); + + it("Doesn't add the client session context entity when anonymous tracking without session tracking", (done) => { + const tracker = createTracker({ + contexts: { session: true }, + encodeBase64: false, + anonymousTracking: true, + plugins: [ + { + afterTrack: (payload) => { + let context = payload.co as string; + expect(context).not.toContain('client_session'); + done(); + }, + }, + ], + }); + + tracker?.trackPageView(); + }); }); + +function createTracker(configuration?: TrackerConfiguration) { + let id = 'sp-' + Math.random(); + return addTracker(id, id, '', '', new SharedState(), configuration); +} 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": [ + ] +} 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..67153b30a --- /dev/null +++ b/plugins/browser-plugin-focalmeter/package.json @@ -0,0 +1,53 @@ +{ + "name": "@snowplow/browser-plugin-focalmeter", + "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", + "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.8.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..72841b010 --- /dev/null +++ b/plugins/browser-plugin-focalmeter/src/index.ts @@ -0,0 +1,156 @@ +/* + * 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 { + attemptGetLocalStorage, + BrowserPlugin, + hasLocalStorage, + BrowserTracker, + attemptWriteLocalStorage, +} from '@snowplow/browser-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 + * + * 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. + * + * Call `enableFocalMeterIntegration()` to enable the integration with given configuration. + */ +export function FocalMeterPlugin(): BrowserPlugin { + let LOG: Logger; + let lastUserId: string | undefined | null; + let trackerId: string; + + return { + activateBrowserPlugin: (tracker: BrowserTracker) => { + trackerId = tracker.id; + _trackers[tracker.id] = tracker; + }, + + 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 { kantarEndpoint, useLocalStorage } = _configurations[trackerId]; + + if (!lastUserId && useLocalStorage && hasLocalStorage()) { + let key = getLocalStorageKey(trackerId); + lastUserId = attemptGetLocalStorage(key); + } + + 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(trackerId); + attemptWriteLocalStorage(key, newUserId); + } + }); + } + }, + }; +} + +/** + * 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): void { + const xhr = new XMLHttpRequest(); + 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}`); + } + } + }; + + xhr.send(); +} + +function getKantarURL(url: string, userId: string): 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 new file mode 100644 index 000000000..6c609e67b --- /dev/null +++ b/plugins/browser-plugin-focalmeter/test/request.test.ts @@ -0,0 +1,148 @@ +/* + * 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, SharedState } from '@snowplow/browser-tracker-core'; +import { enableFocalMeterIntegration, 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(); + enableFocalMeterIntegration({ kantarEndpoint: 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(); + enableFocalMeterIntegration({ kantarEndpoint: 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); + }); + }); + + it('can work with multiple trackers', async () => { + let tracker1 = createTrackerWithPlugin(); + let tracker2 = createTrackerWithPlugin(); + + enableFocalMeterIntegration( + { + kantarEndpoint: domain, + }, + [tracker1!.namespace, tracker2!.namespace] + ); + + // 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(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: [FocalMeterPlugin()], + }); + } + + 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" } ] }