From 1f76423bfa7e442fe786e369b6d90a0b1c58454c Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 13:13:35 +1000 Subject: [PATCH 1/7] feat(audience): scaffold @imtbl/pixel package with attribution, loader, and snippet Add the pixel package scaffold and three self-contained modules that have no dependency on PR #2824. The package builds to a single IIFE bundle (dist/imtbl.js) targeting <10KB gzipped (currently 823 bytes). Modules: - attribution: UTM params, ad click IDs, referrer, landing page (session-cached) - loader: command-queue pattern (window.__imtbl) with pre-load replay - snippet: embeddable '); + expect(html).toContain('https://cdn.immutable.com/pixel/v1/imtbl.js'); + expect(html).toContain('"key":"pk_test_123"'); + }); + + it('uses a custom CDN URL when provided', () => { + const html = generateSnippet({ + key: 'pk_test_123', + cdnUrl: 'https://cdn.dev.immutable.com/pixel/v1/imtbl.js', + }); + + expect(html).toContain('https://cdn.dev.immutable.com/pixel/v1/imtbl.js'); + expect(html).not.toContain('https://cdn.immutable.com/pixel/v1/imtbl.js'); + }); + + it('includes consent level when provided', () => { + const html = generateSnippet({ key: 'pk_test_123', consent: 'anonymous' }); + + expect(html).toContain('"consent":"anonymous"'); + }); + + it('omits consent from init args when not provided', () => { + const html = generateSnippet({ key: 'pk_test_123' }); + + expect(html).not.toContain('consent'); + }); + + it('creates the __imtbl stub array and pushes init command', () => { + const html = generateSnippet({ key: 'pk_test_123' }); + + expect(html).toContain('w[i]=w[i]||[]'); + expect(html).toContain('w[i].push(["init"'); + }); + + it('loads the script asynchronously', () => { + const html = generateSnippet({ key: 'pk_test_123' }); + + expect(html).toContain('s.async=1'); + expect(html).toContain('document.head.appendChild(s)'); + }); +}); diff --git a/packages/audience/pixel/src/snippet.ts b/packages/audience/pixel/src/snippet.ts new file mode 100644 index 0000000000..ef5c7d59bb --- /dev/null +++ b/packages/audience/pixel/src/snippet.ts @@ -0,0 +1,29 @@ +const DEFAULT_CDN_URL = 'https://cdn.immutable.com/pixel/v1/imtbl.js'; + +export interface SnippetOptions { + key: string; + cdnUrl?: string; + consent?: 'none' | 'anonymous' | 'full'; +} + +export function generateSnippet(options: SnippetOptions): string { + const { key, cdnUrl = DEFAULT_CDN_URL, consent } = options; + + const initArgs: Record = { key }; + if (consent) { + initArgs.consent = consent; + } + + const argsJson = JSON.stringify(initArgs); + + return [ + '', + ].join(''); +} diff --git a/packages/audience/pixel/tsconfig.eslint.json b/packages/audience/pixel/tsconfig.eslint.json new file mode 100644 index 0000000000..7a70f2c77d --- /dev/null +++ b/packages/audience/pixel/tsconfig.eslint.json @@ -0,0 +1,5 @@ +{ + "extends": "./tsconfig.json", + "include": ["src"], + "exclude": [] +} diff --git a/packages/audience/pixel/tsconfig.json b/packages/audience/pixel/tsconfig.json new file mode 100644 index 0000000000..4840acb09d --- /dev/null +++ b/packages/audience/pixel/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDirs": ["src"], + "customConditions": ["development"] + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "src/**/*.test.ts"] +} diff --git a/packages/audience/pixel/tsup.config.ts b/packages/audience/pixel/tsup.config.ts new file mode 100644 index 0000000000..9c12136721 --- /dev/null +++ b/packages/audience/pixel/tsup.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + outDir: 'dist', + format: ['iife'], + globalName: '__imtblPixelInternal', + platform: 'browser', + target: 'es2020', + minify: true, + treeshake: true, + splitting: false, + sourcemap: false, + clean: true, + outExtension: () => ({ js: '.js' }), + esbuildOptions(options) { + options.outbase = 'src'; + options.entryNames = 'imtbl'; + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 64014f7b3e..b643082044 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 0.25.21(@emotion/react@11.11.3(@types/react@18.3.12)(react@18.3.1))(@rive-app/react-canvas-lite@4.9.0(react@18.3.1))(embla-carousel-react@8.1.5(react@18.3.1))(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@imtbl/sdk': specifier: latest - version: 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) + version: 2.12.6(typescript@5.6.2) next: specifier: 14.2.25 version: 14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1031,6 +1031,33 @@ importers: specifier: ^5.6.2 version: 5.6.2 + packages/audience/pixel: + devDependencies: + '@swc/core': + specifier: ^1.4.2 + version: 1.15.3(@swc/helpers@0.5.15) + '@swc/jest': + specifier: ^0.2.37 + version: 0.2.37(@swc/core@1.15.3(@swc/helpers@0.5.15)) + '@types/jest': + specifier: ^29.5.12 + version: 29.5.14 + eslint: + specifier: ^8.56.0 + version: 8.57.0 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + jest-environment-jsdom: + specifier: ^29.4.3 + version: 29.6.1(bufferutil@4.0.8)(utf-8-validate@5.0.10) + tsup: + specifier: ^8.3.0 + version: 8.3.0(@swc/core@1.15.3(@swc/helpers@0.5.15))(jiti@1.21.0)(postcss@8.4.49)(typescript@5.6.2)(yaml@2.5.0) + typescript: + specifier: ^5.6.2 + version: 5.6.2 + packages/auth: dependencies: '@imtbl/generated-clients': @@ -19682,7 +19709,7 @@ snapshots: '@confio/ics23@0.6.8': dependencies: - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.8.0 protobufjs: 6.11.4 '@cosmjs/amino@0.31.3': @@ -19721,7 +19748,7 @@ snapshots: '@cosmjs/encoding': 0.31.3 '@cosmjs/math': 0.31.3 '@cosmjs/utils': 0.31.3 - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.8.0 bn.js: 5.2.1 elliptic: 6.6.1 libsodium-wrappers-sumo: 0.7.15 @@ -19731,7 +19758,7 @@ snapshots: '@cosmjs/encoding': 0.32.4 '@cosmjs/math': 0.32.4 '@cosmjs/utils': 0.32.4 - '@noble/hashes': 1.5.0 + '@noble/hashes': 1.8.0 bn.js: 5.2.1 elliptic: 6.6.1 libsodium-wrappers-sumo: 0.7.15 @@ -20000,11 +20027,11 @@ snapshots: '@emnapi/core@1.2.0': dependencies: '@emnapi/wasi-threads': 1.0.1 - tslib: 2.7.0 + tslib: 2.8.1 '@emnapi/runtime@1.2.0': dependencies: - tslib: 2.7.0 + tslib: 2.8.1 '@emnapi/runtime@1.8.1': dependencies: @@ -20013,7 +20040,7 @@ snapshots: '@emnapi/wasi-threads@1.0.1': dependencies: - tslib: 2.7.0 + tslib: 2.8.1 '@emotion/babel-plugin@11.11.0': dependencies: @@ -20764,7 +20791,7 @@ snapshots: transitivePeerDependencies: - debug - '@imtbl/bridge-sdk@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/bridge-sdk@2.12.6': dependencies: '@imtbl/config': 2.12.6 '@jest/globals': 29.7.0 @@ -20776,16 +20803,16 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/checkout-sdk@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/checkout-sdk@2.12.6(typescript@5.6.2)': dependencies: '@imtbl/blockchain-data': 2.12.6 - '@imtbl/bridge-sdk': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/bridge-sdk': 2.12.6 '@imtbl/config': 2.12.6 - '@imtbl/dex-sdk': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/dex-sdk': 2.12.6 '@imtbl/generated-clients': 2.12.6 '@imtbl/metrics': 2.12.6 - '@imtbl/orderbook': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/passport': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/orderbook': 2.12.6 + '@imtbl/passport': 2.12.6(typescript@5.6.2) '@metamask/detect-provider': 2.0.0 axios: 1.7.7 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20847,7 +20874,7 @@ snapshots: - typescript - utf-8-validate - '@imtbl/dex-sdk@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/dex-sdk@2.12.6': dependencies: '@imtbl/config': 2.12.6 '@uniswap/sdk-core': 3.2.3 @@ -20889,7 +20916,7 @@ snapshots: - debug - pg-native - '@imtbl/orderbook@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/orderbook@2.12.6': dependencies: '@imtbl/config': 2.12.6 '@imtbl/metrics': 2.12.6 @@ -20903,16 +20930,16 @@ snapshots: - debug - utf-8-validate - '@imtbl/passport@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/passport@2.12.6(typescript@5.6.2)': dependencies: '@imtbl/auth': 2.12.6 '@imtbl/config': 2.12.6 '@imtbl/generated-clients': 2.12.6 '@imtbl/metrics': 2.12.6 - '@imtbl/toolkit': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/wallet': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) - '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-provider': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/toolkit': 2.12.6 + '@imtbl/wallet': 2.12.6(typescript@5.6.2) + '@imtbl/x-client': 2.12.6 + '@imtbl/x-provider': 2.12.6 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) localforage: 1.10.0 oidc-client-ts: 3.4.1 @@ -20931,19 +20958,19 @@ snapshots: - encoding - supports-color - '@imtbl/sdk@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/sdk@2.12.6(typescript@5.6.2)': dependencies: '@imtbl/auth': 2.12.6 '@imtbl/blockchain-data': 2.12.6 - '@imtbl/checkout-sdk': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/checkout-sdk': 2.12.6(typescript@5.6.2) '@imtbl/config': 2.12.6 '@imtbl/minting-backend': 2.12.6 - '@imtbl/orderbook': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/passport': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) - '@imtbl/wallet': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/orderbook': 2.12.6 + '@imtbl/passport': 2.12.6(typescript@5.6.2) + '@imtbl/wallet': 2.12.6(typescript@5.6.2) '@imtbl/webhook': 2.12.6 - '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-provider': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-client': 2.12.6 + '@imtbl/x-provider': 2.12.6 transitivePeerDependencies: - bufferutil - debug @@ -20954,9 +20981,9 @@ snapshots: - utf-8-validate - zod - '@imtbl/toolkit@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/toolkit@2.12.6': dependencies: - '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-client': 2.12.6 '@metamask/detect-provider': 2.0.0 axios: 1.7.7 bn.js: 5.2.1 @@ -20968,7 +20995,7 @@ snapshots: - debug - utf-8-validate - '@imtbl/wallet@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/wallet@2.12.6(typescript@5.6.2)': dependencies: '@imtbl/auth': 2.12.6 '@imtbl/generated-clients': 2.12.6 @@ -20989,7 +21016,7 @@ snapshots: transitivePeerDependencies: - debug - '@imtbl/x-client@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/x-client@2.12.6': dependencies: '@ethereumjs/wallet': 2.0.4 '@imtbl/config': 2.12.6 @@ -21005,12 +21032,12 @@ snapshots: - debug - utf-8-validate - '@imtbl/x-provider@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': + '@imtbl/x-provider@2.12.6': dependencies: '@imtbl/config': 2.12.6 '@imtbl/generated-clients': 2.12.6 - '@imtbl/toolkit': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) - '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/toolkit': 2.12.6 + '@imtbl/x-client': 2.12.6 '@metamask/detect-provider': 2.0.0 axios: 1.7.7 enc-utils: 3.0.0 @@ -21825,7 +21852,7 @@ snapshots: '@ethereumjs/tx': 4.2.0 '@metamask/superstruct': 3.1.0 '@noble/hashes': 1.8.0 - '@scure/base': 1.1.7 + '@scure/base': 1.2.6 '@types/debug': 4.1.8 debug: 4.3.7(supports-color@8.1.1) pony-cause: 2.1.11 @@ -21839,7 +21866,7 @@ snapshots: '@ethereumjs/tx': 4.2.0 '@metamask/superstruct': 3.1.0 '@noble/hashes': 1.8.0 - '@scure/base': 1.1.7 + '@scure/base': 1.2.6 '@types/debug': 4.1.8 debug: 4.3.7(supports-color@8.1.1) pony-cause: 2.1.11 @@ -21870,7 +21897,7 @@ snapshots: '@motionone/easing': 10.17.0 '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 - tslib: 2.7.0 + tslib: 2.8.1 '@motionone/dom@10.17.0': dependencies: @@ -21879,23 +21906,23 @@ snapshots: '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 hey-listen: 1.0.8 - tslib: 2.7.0 + tslib: 2.8.1 '@motionone/easing@10.17.0': dependencies: '@motionone/utils': 10.17.0 - tslib: 2.7.0 + tslib: 2.8.1 '@motionone/generators@10.17.0': dependencies: '@motionone/types': 10.17.0 '@motionone/utils': 10.17.0 - tslib: 2.7.0 + tslib: 2.8.1 '@motionone/svelte@10.16.4': dependencies: '@motionone/dom': 10.17.0 - tslib: 2.7.0 + tslib: 2.8.1 '@motionone/types@10.17.0': {} @@ -21903,12 +21930,12 @@ snapshots: dependencies: '@motionone/types': 10.17.0 hey-listen: 1.0.8 - tslib: 2.7.0 + tslib: 2.8.1 '@motionone/vue@10.16.4': dependencies: '@motionone/dom': 10.17.0 - tslib: 2.7.0 + tslib: 2.8.1 '@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.2': optional: true @@ -23157,7 +23184,7 @@ snapshots: is-glob: 4.0.3 open: 9.1.0 picocolors: 1.1.1 - tslib: 2.7.0 + tslib: 2.8.1 '@playwright/test@1.45.3': dependencies: @@ -24894,7 +24921,7 @@ snapshots: '@swc/helpers@0.5.13': dependencies: - tslib: 2.7.0 + tslib: 2.8.1 '@swc/helpers@0.5.15': dependencies: @@ -25010,7 +25037,7 @@ snapshots: '@tybys/wasm-util@0.9.0': dependencies: - tslib: 2.7.0 + tslib: 2.8.1 '@typechain/ethers-v6@0.5.1(ethers@6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10))(typechain@8.3.0(typescript@5.6.2))(typescript@5.6.2)': dependencies: @@ -27243,7 +27270,7 @@ snapshots: camel-case@4.1.2: dependencies: pascal-case: 3.1.2 - tslib: 2.7.0 + tslib: 2.8.1 camelcase-css@2.0.1: {} @@ -28367,7 +28394,7 @@ snapshots: dot-case@3.0.4: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 dotenv-expand@11.0.6: dependencies: @@ -33912,7 +33939,7 @@ snapshots: no-case@3.0.4: dependencies: lower-case: 2.0.2 - tslib: 2.7.0 + tslib: 2.8.1 nocache@3.0.4: {} @@ -34451,7 +34478,7 @@ snapshots: param-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 parcel@2.16.3(@swc/helpers@0.5.15): dependencies: @@ -34511,7 +34538,7 @@ snapshots: pascal-case@3.1.2: dependencies: no-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 pascalcase@0.1.1: {} @@ -36749,7 +36776,7 @@ snapshots: snake-case@3.0.4: dependencies: dot-case: 3.0.4 - tslib: 2.7.0 + tslib: 2.8.1 snapdragon-node@2.1.1: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8043dcd6aa..3366bd5f10 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -25,6 +25,7 @@ packages: - "packages/checkout/widgets-lib" - "packages/blockchain-data/sdk" - "packages/audience/core" + - "packages/audience/pixel" - "packages/game-bridge" - "packages/webhook/sdk" - "packages/minting-backend/sdk" From 09736d77d971e96f432cc072d119274bbddb0cad Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 14:38:06 +1000 Subject: [PATCH 2/7] fix(audience): add missing click IDs and attribution fields per event reference Add dclid (Google DV360) and li_fat_id (LinkedIn) to match the Tracking Pixel Event Reference doc. Also add referral_code parsing and touchpoint_type derivation (set to 'click' when UTMs or click IDs are present). Co-Authored-By: Claude Opus 4.6 --- .../audience/pixel/src/attribution.test.ts | 41 ++++++++++++++++--- packages/audience/pixel/src/attribution.ts | 16 ++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/audience/pixel/src/attribution.test.ts b/packages/audience/pixel/src/attribution.test.ts index 6bd82647a1..b742260a35 100644 --- a/packages/audience/pixel/src/attribution.test.ts +++ b/packages/audience/pixel/src/attribution.test.ts @@ -31,14 +31,16 @@ describe('collectAttribution', () => { it('parses ad network click IDs', () => { setLocation( - 'https://example.com/?gclid=abc123&fbclid=fb456&ttclid=tt789&msclkid=ms000', + 'https://example.com/?gclid=abc&dclid=dc1&fbclid=fb2&ttclid=tt3&msclkid=ms4&li_fat_id=li5', ); const result = collectAttribution(); - expect(result.gclid).toBe('abc123'); - expect(result.fbclid).toBe('fb456'); - expect(result.ttclid).toBe('tt789'); - expect(result.msclkid).toBe('ms000'); + expect(result.gclid).toBe('abc'); + expect(result.dclid).toBe('dc1'); + expect(result.fbclid).toBe('fb2'); + expect(result.ttclid).toBe('tt3'); + expect(result.msclkid).toBe('ms4'); + expect(result.li_fat_id).toBe('li5'); }); it('captures referrer and landing page', () => { @@ -65,6 +67,35 @@ describe('collectAttribution', () => { expect(second.utm_source).toBe('google'); }); + it('parses referral_code from the URL', () => { + setLocation('https://example.com/?referral_code=PARTNER42'); + + const result = collectAttribution(); + expect(result.referral_code).toBe('PARTNER42'); + }); + + it('sets touchpoint_type to click when UTMs are present', () => { + setLocation('https://example.com/?utm_source=google'); + + const result = collectAttribution(); + expect(result.touchpoint_type).toBe('click'); + }); + + it('sets touchpoint_type to click when a click ID is present', () => { + setLocation('https://example.com/?gclid=abc123'); + + const result = collectAttribution(); + expect(result.touchpoint_type).toBe('click'); + }); + + it('does not set touchpoint_type when no UTMs or click IDs are present', () => { + setLocation('https://example.com/'); + Object.defineProperty(document, 'referrer', { value: 'https://other.com', configurable: true }); + + const result = collectAttribution(); + expect(result.touchpoint_type).toBeUndefined(); + }); + it('returns empty attribution when no params are present', () => { setLocation('https://example.com/'); Object.defineProperty(document, 'referrer', { value: '', configurable: true }); diff --git a/packages/audience/pixel/src/attribution.ts b/packages/audience/pixel/src/attribution.ts index 61ffe26b31..0046ba054d 100644 --- a/packages/audience/pixel/src/attribution.ts +++ b/packages/audience/pixel/src/attribution.ts @@ -8,9 +8,11 @@ const UTM_PARAMS = [ const CLICK_ID_PARAMS = [ 'gclid', + 'dclid', 'fbclid', 'ttclid', 'msclkid', + 'li_fat_id', ] as const; const STORAGE_KEY = '__imtbl_attribution'; @@ -22,11 +24,15 @@ export interface Attribution { utm_content?: string; utm_term?: string; gclid?: string; + dclid?: string; fbclid?: string; ttclid?: string; msclkid?: string; + li_fat_id?: string; + referral_code?: string; referrer?: string; landing_page?: string; + touchpoint_type?: string; } type AttributionKey = keyof Attribution; @@ -46,6 +52,12 @@ function parseParams(url: string): Attribution { result[key as AttributionKey] = value; } } + + const referralCode = params.get('referral_code'); + if (referralCode) { + result.referral_code = referralCode; + } + return result; } @@ -79,10 +91,14 @@ export function collectAttribution(): Attribution { ? window.location.href : undefined; + const hasClickId = CLICK_ID_PARAMS.some((key) => key in urlParams); + const hasUtm = UTM_PARAMS.some((key) => key in urlParams); + const attribution: Attribution = { ...urlParams, referrer, landing_page: landingPage, + touchpoint_type: hasClickId || hasUtm ? 'click' : undefined, }; saveToStorage(attribution); From 8f6bce0f0911a9cac403b29998b9e32eb88e0a1d Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 14:27:18 +1000 Subject: [PATCH 3/7] feat(audience): add pixel core class, consent state machine, and session cookie Wire the pixel to audience-core now that PR #2824 has merged: - Consent state machine: three-level (none/anonymous/full), DNT/GPC detection, queue purge on downgrade to none, userId strip on downgrade to anonymous, fire-and-forget PUT to /v1/audience/tracking-consent - Session cookie: _imtbl_sid with 30-min rolling expiry - Pixel class: init creates MessageQueue with storagePrefix isolation, auto-fires PageMessage with attribution context, supports identify at full consent, setConsent, and destroy - Build config: resolves audience-core from source via tsup alias for tree-shaken self-contained IIFE bundle (8.04 KB raw / 3.2 KB gzipped) - Metrics stub: no-op stubs so the pixel bundle doesn't ship internal telemetry Co-Authored-By: Claude Opus 4.6 --- packages/audience/pixel/jest.config.ts | 1 + packages/audience/pixel/package.json | 8 +- packages/audience/pixel/src/consent.test.ts | 122 ++++++++++++ packages/audience/pixel/src/consent.ts | 87 +++++++++ packages/audience/pixel/src/index.ts | 8 + packages/audience/pixel/src/pixel.test.ts | 190 +++++++++++++++++++ packages/audience/pixel/src/pixel.ts | 164 ++++++++++++++++ packages/audience/pixel/src/session.test.ts | 57 ++++++ packages/audience/pixel/src/session.ts | 27 +++ packages/audience/pixel/src/stubs/metrics.ts | 8 + packages/audience/pixel/tsconfig.json | 5 +- packages/audience/pixel/tsup.config.ts | 10 + pnpm-lock.yaml | 60 +++--- 13 files changed, 715 insertions(+), 32 deletions(-) create mode 100644 packages/audience/pixel/src/consent.test.ts create mode 100644 packages/audience/pixel/src/consent.ts create mode 100644 packages/audience/pixel/src/pixel.test.ts create mode 100644 packages/audience/pixel/src/pixel.ts create mode 100644 packages/audience/pixel/src/session.test.ts create mode 100644 packages/audience/pixel/src/session.ts create mode 100644 packages/audience/pixel/src/stubs/metrics.ts diff --git a/packages/audience/pixel/jest.config.ts b/packages/audience/pixel/jest.config.ts index 59981c1e5a..63750647c9 100644 --- a/packages/audience/pixel/jest.config.ts +++ b/packages/audience/pixel/jest.config.ts @@ -3,6 +3,7 @@ import type { Config } from 'jest'; const config: Config = { roots: ['/src'], moduleDirectories: ['node_modules', 'src'], + moduleNameMapper: { '^@imtbl/(.*)$': '/../../../node_modules/@imtbl/$1/src' }, testEnvironment: 'jsdom', transform: { '^.+\\.(t|j)sx?$': '@swc/jest', diff --git a/packages/audience/pixel/package.json b/packages/audience/pixel/package.json index 9a27876d2a..65e18d73ee 100644 --- a/packages/audience/pixel/package.json +++ b/packages/audience/pixel/package.json @@ -5,7 +5,9 @@ "author": "Immutable", "private": true, "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", - "dependencies": {}, + "dependencies": { + "@imtbl/audience-core": "workspace:*" + }, "devDependencies": { "@swc/core": "^1.4.2", "@swc/jest": "^0.2.37", @@ -33,10 +35,10 @@ "scripts": { "build": "pnpm transpile && pnpm typegen", "transpile": "tsup", - "typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types", + "typegen": "tsc --customConditions development --emitDeclarationOnly --outDir dist/types", "lint": "eslint ./src --ext .ts --max-warnings=0", "test": "jest --passWithNoTests", - "typecheck": "tsc --customConditions default --noEmit" + "typecheck": "tsc --customConditions development --noEmit" }, "type": "module", "types": "./dist/types/index.d.ts" diff --git a/packages/audience/pixel/src/consent.test.ts b/packages/audience/pixel/src/consent.test.ts new file mode 100644 index 0000000000..b29becfffe --- /dev/null +++ b/packages/audience/pixel/src/consent.test.ts @@ -0,0 +1,122 @@ +import { createConsentManager } from './consent'; + +// Mock audience-core +jest.mock('@imtbl/audience-core', () => ({ + httpSend: jest.fn().mockResolvedValue(true), + CONSENT_PATH: '/v1/audience/tracking-consent', + getBaseUrl: jest.fn().mockReturnValue('https://api.dev.immutable.com'), +})); + +// Mock fetch globally +const mockFetch = jest.fn().mockResolvedValue({ ok: true }); +global.fetch = mockFetch; + +function createMockQueue() { + return { + purge: jest.fn(), + transform: jest.fn(), + enqueue: jest.fn(), + flush: jest.fn(), + flushUnload: jest.fn(), + start: jest.fn(), + stop: jest.fn(), + destroy: jest.fn(), + clear: jest.fn(), + get length() { return 0; }, + } as any; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('createConsentManager', () => { + it('defaults to none when no initial level provided', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev'); + expect(manager.level).toBe('none'); + }); + + it('uses the initial level when provided', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'anonymous'); + expect(manager.level).toBe('anonymous'); + }); + + it('upgrades consent without modifying queue', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'none'); + + manager.setLevel('anonymous'); + expect(manager.level).toBe('anonymous'); + expect(queue.purge).not.toHaveBeenCalled(); + expect(queue.transform).not.toHaveBeenCalled(); + }); + + it('purges queue on downgrade to none', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'full'); + + manager.setLevel('none'); + expect(manager.level).toBe('none'); + expect(queue.purge).toHaveBeenCalledWith(expect.any(Function)); + + // Verify the purge predicate matches all messages + const purgeFn = queue.purge.mock.calls[0][0]; + expect(purgeFn({ type: 'page' })).toBe(true); + }); + + it('strips userId on downgrade from full to anonymous', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'full'); + + manager.setLevel('anonymous'); + expect(manager.level).toBe('anonymous'); + expect(queue.transform).toHaveBeenCalledWith(expect.any(Function)); + + // Verify the transform strips userId + const transformFn = queue.transform.mock.calls[0][0]; + const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' }; + const result = transformFn(withUserId); + expect(result.userId).toBeUndefined(); + expect(result.anonymousId).toBe('a-1'); + }); + + it('fires PUT to consent endpoint on level change', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'none'); + + manager.setLevel('anonymous'); + + expect(mockFetch).toHaveBeenCalledWith( + 'https://api.dev.immutable.com/v1/audience/tracking-consent', + expect.objectContaining({ + method: 'PUT', + headers: expect.objectContaining({ + 'Content-Type': 'application/json', + 'x-immutable-publishable-key': 'pk_test', + }), + }), + ); + }); + + it('does nothing when setting the same level', () => { + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'anonymous'); + + manager.setLevel('anonymous'); + expect(queue.purge).not.toHaveBeenCalled(); + expect(queue.transform).not.toHaveBeenCalled(); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it('respects DNT by defaulting to none', () => { + Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true }); + + const queue = createMockQueue(); + const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev'); + expect(manager.level).toBe('none'); + + Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true }); + }); +}); diff --git a/packages/audience/pixel/src/consent.ts b/packages/audience/pixel/src/consent.ts new file mode 100644 index 0000000000..03b78f1e27 --- /dev/null +++ b/packages/audience/pixel/src/consent.ts @@ -0,0 +1,87 @@ +import type { + ConsentLevel, Message, Environment, MessageQueue, +} from '@imtbl/audience-core'; +import { CONSENT_PATH, getBaseUrl } from '@imtbl/audience-core'; + +export interface ConsentManager { + level: ConsentLevel; + setLevel(next: ConsentLevel): void; +} + +function detectDoNotTrack(): boolean { + if (typeof navigator === 'undefined') return false; + // DNT header + if (navigator.doNotTrack === '1') return true; + // Global Privacy Control + if ((navigator as unknown as Record).globalPrivacyControl === true) return true; + return false; +} + +/** + * Create a consent state machine. + * + * - Default level is `'none'` (no collection). + * - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`. + * - On downgrade (e.g. full → anonymous), strips `userId` from queued messages. + * - On downgrade to `'none'`, purges the queue entirely. + * - Fires PUT to `/v1/audience/tracking-consent` on every state change. + */ +export function createConsentManager( + queue: MessageQueue, + publishableKey: string, + anonymousId: string, + environment: Environment, + initialLevel?: ConsentLevel, +): ConsentManager { + const dntDetected = detectDoNotTrack(); + let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none'); + + const LEVELS: Record = { none: 0, anonymous: 1, full: 2 }; + + function notifyBackend(level: ConsentLevel): void { + const url = `${getBaseUrl(environment)}${CONSENT_PATH}`; + const payload = { anonymousId, consentLevel: level }; + fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-immutable-publishable-key': publishableKey, + }, + body: JSON.stringify(payload), + keepalive: true, + }).catch(() => {}); + } + + const manager: ConsentManager = { + get level() { + return current; + }, + + setLevel(next: ConsentLevel): void { + if (next === current) return; + + const isDowngrade = LEVELS[next] < LEVELS[current]; + + if (isDowngrade) { + if (next === 'none') { + // Purge all queued messages + queue.purge(() => true); + } else if (next === 'anonymous') { + // Strip userId from queued messages + queue.transform((msg: Message) => { + if ('userId' in msg) { + const { userId, ...rest } = msg; + return rest as Message; + } + return msg; + }); + } + } + + current = next; + notifyBackend(next); + }, + }; + + return manager; +} diff --git a/packages/audience/pixel/src/index.ts b/packages/audience/pixel/src/index.ts index d6d235bc7f..f597f4fa7b 100644 --- a/packages/audience/pixel/src/index.ts +++ b/packages/audience/pixel/src/index.ts @@ -1,3 +1,11 @@ +export { Pixel } from './pixel'; +export type { PixelInitOptions } from './pixel'; + +export { createConsentManager } from './consent'; +export type { ConsentManager } from './consent'; + +export { getOrCreateSessionId, getSessionId } from './session'; + export { collectAttribution, clearAttribution } from './attribution'; export type { Attribution } from './attribution'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts new file mode 100644 index 0000000000..046e7ea2e9 --- /dev/null +++ b/packages/audience/pixel/src/pixel.test.ts @@ -0,0 +1,190 @@ +import { Pixel } from './pixel'; + +// Mock audience-core +const mockEnqueue = jest.fn(); +const mockStart = jest.fn(); +const mockDestroy = jest.fn(); +const mockPurge = jest.fn(); +const mockTransform = jest.fn(); + +jest.mock('@imtbl/audience-core', () => ({ + MessageQueue: jest.fn().mockImplementation(() => ({ + enqueue: mockEnqueue, + start: mockStart, + destroy: mockDestroy, + purge: mockPurge, + transform: mockTransform, + stop: jest.fn(), + flush: jest.fn(), + flushUnload: jest.fn(), + clear: jest.fn(), + get length() { return 0; }, + })), + httpTransport: { send: jest.fn().mockResolvedValue(true) }, + httpSend: jest.fn().mockResolvedValue(true), + getBaseUrl: jest.fn().mockReturnValue('https://api.dev.immutable.com'), + INGEST_PATH: '/v1/audience/messages', + CONSENT_PATH: '/v1/audience/tracking-consent', + FLUSH_INTERVAL_MS: 5000, + FLUSH_SIZE: 20, + getOrCreateAnonymousId: jest.fn().mockReturnValue('anon-123'), + collectContext: jest.fn().mockReturnValue({ + library: '@imtbl/pixel', + libraryVersion: '0.0.0', + userAgent: 'test', + }), + generateId: jest.fn().mockReturnValue('msg-uuid'), + getTimestamp: jest.fn().mockReturnValue('2026-04-07T00:00:00.000Z'), + getCookie: jest.fn(), + setCookie: jest.fn(), +})); + +// Mock internal modules +jest.mock('./attribution', () => ({ + collectAttribution: jest.fn().mockReturnValue({ + utm_source: 'google', + landing_page: 'https://example.com', + }), +})); + +jest.mock('./session', () => ({ + getOrCreateSessionId: jest.fn().mockReturnValue('session-abc'), +})); + +// Mock fetch globally +global.fetch = jest.fn().mockResolvedValue({ ok: true }); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('Pixel', () => { + describe('init', () => { + it('creates queue, starts it, and fires a page view when consent is anonymous', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + expect(mockStart).toHaveBeenCalled(); + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'page', + surface: 'pixel', + anonymousId: 'anon-123', + properties: expect.objectContaining({ + utm_source: 'google', + sessionId: 'session-abc', + }), + }), + ); + }); + + it('does not fire page view when consent is none', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + expect(mockStart).toHaveBeenCalled(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + + it('only initializes once', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + pixel.init({ key: 'pk_other', environment: 'dev', consent: 'anonymous' }); + + // Start called only once + expect(mockStart).toHaveBeenCalledTimes(1); + }); + }); + + describe('page', () => { + it('enqueues a page message with attribution and session', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + // Clear the auto-fired page view + mockEnqueue.mockClear(); + + pixel.page({ custom: 'prop' }); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'page', + surface: 'pixel', + properties: expect.objectContaining({ + utm_source: 'google', + sessionId: 'session-abc', + custom: 'prop', + }), + }), + ); + }); + + it('does not enqueue when consent is none', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + pixel.page(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + }); + + describe('identify', () => { + it('enqueues identify message at full consent', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'full' }); + mockEnqueue.mockClear(); + + pixel.identify('user-1', { email: 'test@example.com' }); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'identify', + userId: 'user-1', + surface: 'pixel', + traits: expect.objectContaining({ + email: 'test@example.com', + sessionId: 'session-abc', + }), + }), + ); + }); + + it('does not enqueue identify at anonymous consent', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + pixel.identify('user-1'); + // Only the auto page view, no identify + expect(mockEnqueue).toHaveBeenCalledTimes(1); + expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' })); + }); + }); + + describe('setConsent', () => { + it('updates consent level', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + pixel.setConsent('anonymous'); + + // After upgrading consent, page() should work + pixel.page(); + expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' })); + }); + }); + + describe('destroy', () => { + it('destroys the queue and resets state', () => { + const pixel = new Pixel(); + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + pixel.destroy(); + expect(mockDestroy).toHaveBeenCalled(); + + // After destroy, page() should be a no-op + mockEnqueue.mockClear(); + pixel.page(); + expect(mockEnqueue).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts new file mode 100644 index 0000000000..fcd497d9eb --- /dev/null +++ b/packages/audience/pixel/src/pixel.ts @@ -0,0 +1,164 @@ +import type { + Environment, + ConsentLevel, + PageMessage, + IdentifyMessage, + UserTraits, +} from '@imtbl/audience-core'; +import { + MessageQueue, + httpTransport, + getBaseUrl, + INGEST_PATH, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + getOrCreateAnonymousId, + collectContext, + generateId, + getTimestamp, +} from '@imtbl/audience-core'; +import { collectAttribution } from './attribution'; +import { getOrCreateSessionId } from './session'; +import { createConsentManager, ConsentManager } from './consent'; + +const PIXEL_VERSION = '0.0.0'; + +export interface PixelInitOptions { + key: string; + environment?: Environment; + consent?: ConsentLevel; + domain?: string; +} + +export class Pixel { + private queue: MessageQueue | null = null; + + private consent: ConsentManager | null = null; + + private anonymousId = ''; + + private userId: string | undefined; + + private environment: Environment = 'production'; + + private publishableKey = ''; + + private domain: string | undefined; + + private initialized = false; + + init(options: PixelInitOptions): void { + if (this.initialized) return; + + const { + key, + environment = 'production', + consent: consentLevel, + domain, + } = options; + + this.publishableKey = key; + this.environment = environment; + this.domain = domain; + + const endpointUrl = `${getBaseUrl(environment)}${INGEST_PATH}`; + + this.queue = new MessageQueue( + httpTransport, + endpointUrl, + key, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + { storagePrefix: '__imtbl_pixel_' }, + ); + + this.anonymousId = getOrCreateAnonymousId(domain); + + this.consent = createConsentManager( + this.queue, + key, + this.anonymousId, + environment, + consentLevel, + ); + + this.queue.start(); + this.initialized = true; + + // Auto-fire page view if consent allows + if (this.consent.level !== 'none') { + this.page(); + } + } + + page(properties?: Record): void { + if (!this.canTrack()) return; + + const sessionId = getOrCreateSessionId(this.domain); + const attribution = collectAttribution(); + const context = collectContext('@imtbl/pixel', PIXEL_VERSION); + + const message: PageMessage = { + type: 'page', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId!, + surface: 'pixel', + context, + properties: { + ...attribution, + sessionId, + ...properties, + }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + + identify(userId: string, traits?: UserTraits): void { + if (!this.isReady() || this.consent!.level !== 'full') return; + + this.userId = userId; + const sessionId = getOrCreateSessionId(this.domain); + const context = collectContext('@imtbl/pixel', PIXEL_VERSION); + + const message: IdentifyMessage = { + type: 'identify', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId!, + surface: 'pixel', + context, + userId, + traits: { + ...traits, + sessionId, + } as UserTraits, + }; + + this.queue!.enqueue(message); + } + + setConsent(level: ConsentLevel): void { + if (!this.isReady()) return; + this.consent!.setLevel(level); + } + + destroy(): void { + if (this.queue) { + this.queue.destroy(); + this.queue = null; + } + this.consent = null; + this.initialized = false; + } + + private canTrack(): boolean { + return this.isReady() && this.consent!.level !== 'none'; + } + + private isReady(): boolean { + return this.initialized && this.queue !== null && this.consent !== null; + } +} diff --git a/packages/audience/pixel/src/session.test.ts b/packages/audience/pixel/src/session.test.ts new file mode 100644 index 0000000000..248c107ef3 --- /dev/null +++ b/packages/audience/pixel/src/session.test.ts @@ -0,0 +1,57 @@ +import { getOrCreateSessionId, getSessionId } from './session'; + +const SESSION_COOKIE_NAME = '_imtbl_sid'; + +// Mock audience-core cookie helpers +const mockGetCookie = jest.fn(); +const mockSetCookie = jest.fn(); +const mockGenerateId = jest.fn(); + +jest.mock('@imtbl/audience-core', () => ({ + getCookie: (...args: unknown[]) => mockGetCookie(...args), + setCookie: (...args: unknown[]) => mockSetCookie(...args), + generateId: (...args: unknown[]) => mockGenerateId(...args), +})); + +beforeEach(() => { + jest.clearAllMocks(); + mockGenerateId.mockReturnValue('new-session-id'); +}); + +describe('getOrCreateSessionId', () => { + it('creates a new session ID when no cookie exists', () => { + mockGetCookie.mockReturnValue(undefined); + + const id = getOrCreateSessionId(); + expect(id).toBe('new-session-id'); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, undefined); + }); + + it('returns existing session ID and refreshes expiry', () => { + mockGetCookie.mockReturnValue('existing-sid'); + + const id = getOrCreateSessionId(); + expect(id).toBe('existing-sid'); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'existing-sid', 1800, undefined); + expect(mockGenerateId).not.toHaveBeenCalled(); + }); + + it('passes domain to setCookie', () => { + mockGetCookie.mockReturnValue(undefined); + + getOrCreateSessionId('.example.com'); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, '.example.com'); + }); +}); + +describe('getSessionId', () => { + it('returns the session ID from cookie', () => { + mockGetCookie.mockReturnValue('existing-sid'); + expect(getSessionId()).toBe('existing-sid'); + }); + + it('returns undefined when no session cookie exists', () => { + mockGetCookie.mockReturnValue(undefined); + expect(getSessionId()).toBeUndefined(); + }); +}); diff --git a/packages/audience/pixel/src/session.ts b/packages/audience/pixel/src/session.ts new file mode 100644 index 0000000000..2eaf5c2efc --- /dev/null +++ b/packages/audience/pixel/src/session.ts @@ -0,0 +1,27 @@ +import { getCookie, setCookie, generateId } from '@imtbl/audience-core'; + +const SESSION_COOKIE_NAME = '_imtbl_sid'; +const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds + +/** + * Get or create a session ID. + * + * The session cookie has a 30-minute rolling expiry — each call refreshes it. + * This gives us session-scoped grouping of pixel events. + */ +export function getOrCreateSessionId(domain?: string): string { + const existing = getCookie(SESSION_COOKIE_NAME); + if (existing) { + // Refresh the rolling expiry + setCookie(SESSION_COOKIE_NAME, existing, SESSION_MAX_AGE, domain); + return existing; + } + + const id = generateId(); + setCookie(SESSION_COOKIE_NAME, id, SESSION_MAX_AGE, domain); + return id; +} + +export function getSessionId(): string | undefined { + return getCookie(SESSION_COOKIE_NAME); +} diff --git a/packages/audience/pixel/src/stubs/metrics.ts b/packages/audience/pixel/src/stubs/metrics.ts new file mode 100644 index 0000000000..126946a544 --- /dev/null +++ b/packages/audience/pixel/src/stubs/metrics.ts @@ -0,0 +1,8 @@ +// No-op stubs for @imtbl/metrics — the pixel is a self-contained bundle +// and doesn't ship internal telemetry. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const track = (..._args: unknown[]): void => {}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const trackError = (..._args: unknown[]): void => {}; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +export const trackDuration = (..._args: unknown[]): void => {}; diff --git a/packages/audience/pixel/tsconfig.json b/packages/audience/pixel/tsconfig.json index 4840acb09d..2e15eaf9c9 100644 --- a/packages/audience/pixel/tsconfig.json +++ b/packages/audience/pixel/tsconfig.json @@ -3,7 +3,10 @@ "compilerOptions": { "outDir": "./dist", "rootDirs": ["src"], - "customConditions": ["development"] + "customConditions": ["development"], + "paths": { + "@imtbl/metrics": ["./src/stubs/metrics.ts"] + } }, "include": ["src"], "exclude": ["dist", "node_modules", "src/**/*.test.ts"] diff --git a/packages/audience/pixel/tsup.config.ts b/packages/audience/pixel/tsup.config.ts index 9c12136721..c1171ccca7 100644 --- a/packages/audience/pixel/tsup.config.ts +++ b/packages/audience/pixel/tsup.config.ts @@ -1,3 +1,4 @@ +import { resolve } from 'path'; import { defineConfig } from 'tsup'; export default defineConfig({ @@ -12,9 +13,18 @@ export default defineConfig({ splitting: false, sourcemap: false, clean: true, + noExternal: ['@imtbl/audience-core', '@imtbl/metrics'], outExtension: () => ({ js: '.js' }), esbuildOptions(options) { options.outbase = 'src'; options.entryNames = 'imtbl'; + // Resolve @imtbl/audience-core from source so the pixel bundles + // a tree-shaken copy — no runtime dependency on the core package. + // @imtbl/metrics is stubbed out — the pixel is a self-contained + // snippet and doesn't need internal telemetry. + options.alias = { + '@imtbl/audience-core': resolve(__dirname, '../core/src/index.ts'), + '@imtbl/metrics': resolve(__dirname, 'src/stubs/metrics.ts'), + }; }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b643082044..b903080a6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 0.25.21(@emotion/react@11.11.3(@types/react@18.3.12)(react@18.3.1))(@rive-app/react-canvas-lite@4.9.0(react@18.3.1))(embla-carousel-react@8.1.5(react@18.3.1))(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@imtbl/sdk': specifier: latest - version: 2.12.6(typescript@5.6.2) + version: 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) next: specifier: 14.2.25 version: 14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1032,6 +1032,10 @@ importers: version: 5.6.2 packages/audience/pixel: + dependencies: + '@imtbl/audience-core': + specifier: workspace:* + version: link:../core devDependencies: '@swc/core': specifier: ^1.4.2 @@ -20791,7 +20795,7 @@ snapshots: transitivePeerDependencies: - debug - '@imtbl/bridge-sdk@2.12.6': + '@imtbl/bridge-sdk@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@imtbl/config': 2.12.6 '@jest/globals': 29.7.0 @@ -20803,16 +20807,16 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/checkout-sdk@2.12.6(typescript@5.6.2)': + '@imtbl/checkout-sdk@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/blockchain-data': 2.12.6 - '@imtbl/bridge-sdk': 2.12.6 + '@imtbl/bridge-sdk': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@imtbl/config': 2.12.6 - '@imtbl/dex-sdk': 2.12.6 + '@imtbl/dex-sdk': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@imtbl/generated-clients': 2.12.6 '@imtbl/metrics': 2.12.6 - '@imtbl/orderbook': 2.12.6 - '@imtbl/passport': 2.12.6(typescript@5.6.2) + '@imtbl/orderbook': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/passport': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) '@metamask/detect-provider': 2.0.0 axios: 1.7.7 ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20874,7 +20878,7 @@ snapshots: - typescript - utf-8-validate - '@imtbl/dex-sdk@2.12.6': + '@imtbl/dex-sdk@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@imtbl/config': 2.12.6 '@uniswap/sdk-core': 3.2.3 @@ -20916,7 +20920,7 @@ snapshots: - debug - pg-native - '@imtbl/orderbook@2.12.6': + '@imtbl/orderbook@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@imtbl/config': 2.12.6 '@imtbl/metrics': 2.12.6 @@ -20930,16 +20934,16 @@ snapshots: - debug - utf-8-validate - '@imtbl/passport@2.12.6(typescript@5.6.2)': + '@imtbl/passport@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/auth': 2.12.6 '@imtbl/config': 2.12.6 '@imtbl/generated-clients': 2.12.6 '@imtbl/metrics': 2.12.6 - '@imtbl/toolkit': 2.12.6 - '@imtbl/wallet': 2.12.6(typescript@5.6.2) - '@imtbl/x-client': 2.12.6 - '@imtbl/x-provider': 2.12.6 + '@imtbl/toolkit': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/wallet': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-provider': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) localforage: 1.10.0 oidc-client-ts: 3.4.1 @@ -20958,19 +20962,19 @@ snapshots: - encoding - supports-color - '@imtbl/sdk@2.12.6(typescript@5.6.2)': + '@imtbl/sdk@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/auth': 2.12.6 '@imtbl/blockchain-data': 2.12.6 - '@imtbl/checkout-sdk': 2.12.6(typescript@5.6.2) + '@imtbl/checkout-sdk': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) '@imtbl/config': 2.12.6 '@imtbl/minting-backend': 2.12.6 - '@imtbl/orderbook': 2.12.6 - '@imtbl/passport': 2.12.6(typescript@5.6.2) - '@imtbl/wallet': 2.12.6(typescript@5.6.2) + '@imtbl/orderbook': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/passport': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/wallet': 2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) '@imtbl/webhook': 2.12.6 - '@imtbl/x-client': 2.12.6 - '@imtbl/x-provider': 2.12.6 + '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-provider': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil - debug @@ -20981,9 +20985,9 @@ snapshots: - utf-8-validate - zod - '@imtbl/toolkit@2.12.6': + '@imtbl/toolkit@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: - '@imtbl/x-client': 2.12.6 + '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@metamask/detect-provider': 2.0.0 axios: 1.7.7 bn.js: 5.2.1 @@ -20995,7 +20999,7 @@ snapshots: - debug - utf-8-validate - '@imtbl/wallet@2.12.6(typescript@5.6.2)': + '@imtbl/wallet@2.12.6(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/auth': 2.12.6 '@imtbl/generated-clients': 2.12.6 @@ -21016,7 +21020,7 @@ snapshots: transitivePeerDependencies: - debug - '@imtbl/x-client@2.12.6': + '@imtbl/x-client@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@ethereumjs/wallet': 2.0.4 '@imtbl/config': 2.12.6 @@ -21032,12 +21036,12 @@ snapshots: - debug - utf-8-validate - '@imtbl/x-provider@2.12.6': + '@imtbl/x-provider@2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@imtbl/config': 2.12.6 '@imtbl/generated-clients': 2.12.6 - '@imtbl/toolkit': 2.12.6 - '@imtbl/x-client': 2.12.6 + '@imtbl/toolkit': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) + '@imtbl/x-client': 2.12.6(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@metamask/detect-provider': 2.0.0 axios: 1.7.7 enc-utils: 3.0.0 From 938ea87a55ef40ec26bbfe8b2ba3cc48cee23d11 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 15:32:48 +1000 Subject: [PATCH 4/7] feat(audience): add session_start/end events and fix test isolation Add session lifecycle tracking to the Pixel class: - Fire session_start track event on new session creation - Fire session_end track event on pagehide/visibilitychange with duration - Refactor session module to return SessionResult with isNew flag - Fix test listener leaking by cleaning up pixel instances in afterEach Co-Authored-By: Claude Opus 4.6 --- packages/audience/pixel/src/index.ts | 3 +- packages/audience/pixel/src/pixel.test.ts | 139 +++++++++++++++++--- packages/audience/pixel/src/pixel.ts | 100 +++++++++++++- packages/audience/pixel/src/session.test.ts | 34 +++-- packages/audience/pixel/src/session.ts | 18 ++- 5 files changed, 256 insertions(+), 38 deletions(-) diff --git a/packages/audience/pixel/src/index.ts b/packages/audience/pixel/src/index.ts index f597f4fa7b..042b78ea71 100644 --- a/packages/audience/pixel/src/index.ts +++ b/packages/audience/pixel/src/index.ts @@ -4,7 +4,8 @@ export type { PixelInitOptions } from './pixel'; export { createConsentManager } from './consent'; export type { ConsentManager } from './consent'; -export { getOrCreateSessionId, getSessionId } from './session'; +export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; +export type { SessionResult } from './session'; export { collectAttribution, clearAttribution } from './attribution'; export type { Attribution } from './attribution'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index 046e7ea2e9..3a3a795715 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -35,6 +35,7 @@ jest.mock('@imtbl/audience-core', () => ({ }), generateId: jest.fn().mockReturnValue('msg-uuid'), getTimestamp: jest.fn().mockReturnValue('2026-04-07T00:00:00.000Z'), + isBrowser: jest.fn().mockReturnValue(true), getCookie: jest.fn(), setCookie: jest.fn(), })); @@ -48,60 +49,99 @@ jest.mock('./attribution', () => ({ })); jest.mock('./session', () => ({ + getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true }), getOrCreateSessionId: jest.fn().mockReturnValue('session-abc'), })); // Mock fetch globally global.fetch = jest.fn().mockResolvedValue({ ok: true }); +// Access the mock to change return values per test +const mockGetOrCreateSession = jest.requireMock('./session').getOrCreateSession; + +let activePixel: Pixel | null = null; + beforeEach(() => { jest.clearAllMocks(); + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: true }); +}); + +afterEach(() => { + // Clean up any active pixel to remove event listeners + if (activePixel) { + activePixel.destroy(); + activePixel = null; + } }); describe('Pixel', () => { describe('init', () => { - it('creates queue, starts it, and fires a page view when consent is anonymous', () => { + it('creates queue, starts it, and fires page view + session_start when consent is anonymous', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); expect(mockStart).toHaveBeenCalled(); - expect(mockEnqueue).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'page', - surface: 'pixel', - anonymousId: 'anon-123', - properties: expect.objectContaining({ - utm_source: 'google', - sessionId: 'session-abc', - }), - }), + + // Should fire session_start (new session) then page view + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const sessionStartCall = calls.find( + (c) => c.type === 'track' && c.eventName === 'session_start', ); + + expect(pageCall).toBeDefined(); + expect(pageCall!.surface).toBe('pixel'); + expect(pageCall!.anonymousId).toBe('anon-123'); + expect((pageCall!.properties as Record).utm_source).toBe('google'); + + expect(sessionStartCall).toBeDefined(); + expect((sessionStartCall!.properties as Record).sessionId).toBe('session-abc'); }); - it('does not fire page view when consent is none', () => { + it('does not fire page view or session_start when consent is none', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); expect(mockStart).toHaveBeenCalled(); expect(mockEnqueue).not.toHaveBeenCalled(); }); + it('does not fire session_start for existing sessions', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const sessionStartCall = calls.find( + (c) => c.type === 'track' && c.eventName === 'session_start', + ); + + expect(sessionStartCall).toBeUndefined(); + // Page view should still fire + expect(calls.find((c) => c.type === 'page')).toBeDefined(); + }); + it('only initializes once', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); pixel.init({ key: 'pk_other', environment: 'dev', consent: 'anonymous' }); - // Start called only once expect(mockStart).toHaveBeenCalledTimes(1); }); }); describe('page', () => { it('enqueues a page message with attribution and session', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); - - // Clear the auto-fired page view mockEnqueue.mockClear(); pixel.page({ custom: 'prop' }); @@ -121,6 +161,7 @@ describe('Pixel', () => { it('does not enqueue when consent is none', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); pixel.page(); @@ -130,7 +171,10 @@ describe('Pixel', () => { describe('identify', () => { it('enqueues identify message at full consent', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'full' }); mockEnqueue.mockClear(); @@ -151,23 +195,79 @@ describe('Pixel', () => { it('does not enqueue identify at anonymous consent', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); pixel.identify('user-1'); - // Only the auto page view, no identify - expect(mockEnqueue).toHaveBeenCalledTimes(1); - expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' })); + // Only the auto page view + session_start, no identify + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + expect(calls.find((c) => c.type === 'identify')).toBeUndefined(); + }); + }); + + describe('session_end', () => { + it('fires session_end on pagehide when session is active', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + mockEnqueue.mockClear(); + + // Simulate pagehide + window.dispatchEvent(new Event('pagehide')); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'track', + eventName: 'session_end', + properties: expect.objectContaining({ + sessionId: 'session-abc', + }), + }), + ); + }); + + it('includes duration in session_end', () => { + const dateNowSpy = jest.spyOn(Date, 'now').mockReturnValue(1000000); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + mockEnqueue.mockClear(); + + // Advance time by 15 seconds before triggering pagehide + dateNowSpy.mockReturnValue(1015000); + window.dispatchEvent(new Event('pagehide')); + + const sessionEndCall = mockEnqueue.mock.calls.find( + (c: unknown[]) => (c[0] as Record).eventName === 'session_end', + ); + expect(sessionEndCall).toBeDefined(); + expect((sessionEndCall![0] as Record).properties).toEqual( + expect.objectContaining({ duration: 15 }), + ); + + dateNowSpy.mockRestore(); + }); + + it('does not fire session_end when consent is none', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); + + window.dispatchEvent(new Event('pagehide')); + expect(mockEnqueue).not.toHaveBeenCalled(); }); }); describe('setConsent', () => { it('updates consent level', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'none' }); pixel.setConsent('anonymous'); - // After upgrading consent, page() should work + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false }); pixel.page(); expect(mockEnqueue).toHaveBeenCalledWith(expect.objectContaining({ type: 'page' })); }); @@ -176,6 +276,7 @@ describe('Pixel', () => { describe('destroy', () => { it('destroys the queue and resets state', () => { const pixel = new Pixel(); + activePixel = pixel; pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); pixel.destroy(); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index fcd497d9eb..61bcddd4a3 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -2,6 +2,7 @@ import type { Environment, ConsentLevel, PageMessage, + TrackMessage, IdentifyMessage, UserTraits, } from '@imtbl/audience-core'; @@ -16,9 +17,10 @@ import { collectContext, generateId, getTimestamp, + isBrowser, } from '@imtbl/audience-core'; import { collectAttribution } from './attribution'; -import { getOrCreateSessionId } from './session'; +import { getOrCreateSession } from './session'; import { createConsentManager, ConsentManager } from './consent'; const PIXEL_VERSION = '0.0.0'; @@ -39,6 +41,10 @@ export class Pixel { private userId: string | undefined; + private sessionId: string | undefined; + + private sessionStartTime: number | undefined; + private environment: Environment = 'production'; private publishableKey = ''; @@ -47,6 +53,8 @@ export class Pixel { private initialized = false; + private unloadHandler?: () => void; + init(options: PixelInitOptions): void { if (this.initialized) return; @@ -89,12 +97,15 @@ export class Pixel { if (this.consent.level !== 'none') { this.page(); } + + this.registerSessionEnd(); } page(properties?: Record): void { if (!this.canTrack()) return; - const sessionId = getOrCreateSessionId(this.domain); + const { sessionId, isNew } = getOrCreateSession(this.domain); + this.refreshSession(sessionId, isNew); const attribution = collectAttribution(); const context = collectContext('@imtbl/pixel', PIXEL_VERSION); @@ -102,7 +113,7 @@ export class Pixel { type: 'page', messageId: generateId(), eventTimestamp: getTimestamp(), - anonymousId: this.anonymousId!, + anonymousId: this.anonymousId, surface: 'pixel', context, properties: { @@ -120,14 +131,15 @@ export class Pixel { if (!this.isReady() || this.consent!.level !== 'full') return; this.userId = userId; - const sessionId = getOrCreateSessionId(this.domain); + const { sessionId, isNew } = getOrCreateSession(this.domain); + this.refreshSession(sessionId, isNew); const context = collectContext('@imtbl/pixel', PIXEL_VERSION); const message: IdentifyMessage = { type: 'identify', messageId: generateId(), eventTimestamp: getTimestamp(), - anonymousId: this.anonymousId!, + anonymousId: this.anonymousId, surface: 'pixel', context, userId, @@ -146,6 +158,7 @@ export class Pixel { } destroy(): void { + this.removeSessionEnd(); if (this.queue) { this.queue.destroy(); this.queue = null; @@ -154,6 +167,83 @@ export class Pixel { this.initialized = false; } + // -- Session lifecycle -------------------------------------------------- + + private refreshSession(sessionId: string, isNew: boolean): void { + this.sessionId = sessionId; + if (isNew) { + this.sessionStartTime = Date.now(); + this.fireSessionStart(sessionId); + } + } + + private fireSessionStart(sessionId: string): void { + if (!this.canTrack()) return; + + const message: TrackMessage = { + type: 'track', + eventName: 'session_start', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'pixel', + context: collectContext('@imtbl/pixel', PIXEL_VERSION), + properties: { sessionId }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + + private fireSessionEnd(): void { + if (!this.canTrack() || !this.sessionId) return; + + const duration = this.sessionStartTime + ? Math.round((Date.now() - this.sessionStartTime) / 1000) + : undefined; + + const message: TrackMessage = { + type: 'track', + eventName: 'session_end', + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'pixel', + context: collectContext('@imtbl/pixel', PIXEL_VERSION), + properties: { + sessionId: this.sessionId, + duration, + }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + + private registerSessionEnd(): void { + if (!isBrowser()) return; + + this.unloadHandler = () => { + this.fireSessionEnd(); + }; + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') { + this.unloadHandler?.(); + } + }); + window.addEventListener('pagehide', this.unloadHandler); + } + + private removeSessionEnd(): void { + if (this.unloadHandler) { + window.removeEventListener('pagehide', this.unloadHandler); + this.unloadHandler = undefined; + } + } + + // -- Guards ------------------------------------------------------------- + private canTrack(): boolean { return this.isReady() && this.consent!.level !== 'none'; } diff --git a/packages/audience/pixel/src/session.test.ts b/packages/audience/pixel/src/session.test.ts index 248c107ef3..db306653dd 100644 --- a/packages/audience/pixel/src/session.test.ts +++ b/packages/audience/pixel/src/session.test.ts @@ -1,4 +1,4 @@ -import { getOrCreateSessionId, getSessionId } from './session'; +import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; const SESSION_COOKIE_NAME = '_imtbl_sid'; @@ -18,20 +18,22 @@ beforeEach(() => { mockGenerateId.mockReturnValue('new-session-id'); }); -describe('getOrCreateSessionId', () => { - it('creates a new session ID when no cookie exists', () => { +describe('getOrCreateSession', () => { + it('creates a new session when no cookie exists', () => { mockGetCookie.mockReturnValue(undefined); - const id = getOrCreateSessionId(); - expect(id).toBe('new-session-id'); + const result = getOrCreateSession(); + expect(result.sessionId).toBe('new-session-id'); + expect(result.isNew).toBe(true); expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, undefined); }); - it('returns existing session ID and refreshes expiry', () => { + it('returns existing session and refreshes expiry', () => { mockGetCookie.mockReturnValue('existing-sid'); - const id = getOrCreateSessionId(); - expect(id).toBe('existing-sid'); + const result = getOrCreateSession(); + expect(result.sessionId).toBe('existing-sid'); + expect(result.isNew).toBe(false); expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'existing-sid', 1800, undefined); expect(mockGenerateId).not.toHaveBeenCalled(); }); @@ -39,11 +41,25 @@ describe('getOrCreateSessionId', () => { it('passes domain to setCookie', () => { mockGetCookie.mockReturnValue(undefined); - getOrCreateSessionId('.example.com'); + getOrCreateSession('.example.com'); expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, '.example.com'); }); }); +describe('getOrCreateSessionId', () => { + it('returns the session ID string', () => { + mockGetCookie.mockReturnValue(undefined); + + const id = getOrCreateSessionId(); + expect(id).toBe('new-session-id'); + }); + + it('returns existing session ID', () => { + mockGetCookie.mockReturnValue('existing-sid'); + expect(getOrCreateSessionId()).toBe('existing-sid'); + }); +}); + describe('getSessionId', () => { it('returns the session ID from cookie', () => { mockGetCookie.mockReturnValue('existing-sid'); diff --git a/packages/audience/pixel/src/session.ts b/packages/audience/pixel/src/session.ts index 2eaf5c2efc..ce8b29680b 100644 --- a/packages/audience/pixel/src/session.ts +++ b/packages/audience/pixel/src/session.ts @@ -3,23 +3,33 @@ import { getCookie, setCookie, generateId } from '@imtbl/audience-core'; const SESSION_COOKIE_NAME = '_imtbl_sid'; const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds +export interface SessionResult { + sessionId: string; + isNew: boolean; +} + /** * Get or create a session ID. * * The session cookie has a 30-minute rolling expiry — each call refreshes it. - * This gives us session-scoped grouping of pixel events. + * Returns whether the session is new so the caller can fire a `session_start` event. */ -export function getOrCreateSessionId(domain?: string): string { +export function getOrCreateSession(domain?: string): SessionResult { const existing = getCookie(SESSION_COOKIE_NAME); if (existing) { // Refresh the rolling expiry setCookie(SESSION_COOKIE_NAME, existing, SESSION_MAX_AGE, domain); - return existing; + return { sessionId: existing, isNew: false }; } const id = generateId(); setCookie(SESSION_COOKIE_NAME, id, SESSION_MAX_AGE, domain); - return id; + return { sessionId: id, isNew: true }; +} + +/** Convenience wrapper that returns just the session ID string. */ +export function getOrCreateSessionId(domain?: string): string { + return getOrCreateSession(domain).sessionId; } export function getSessionId(): string | undefined { From 51a90085e6a0c06741033d2c7f8cdb0a3c9d56e0 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 15:48:21 +1000 Subject: [PATCH 5/7] refactor(audience): lift session, attribution, and consent into core Move session management, attribution tracking, and consent state machine from @imtbl/pixel into @imtbl/audience-core so the web SDK can share them. - session.ts: use SESSION_COOKIE from core config instead of duplicating - attribution.ts: UTM params, click IDs, referrer, sessionStorage caching - consent.ts: three-level state machine with DNT/GPC, queue purge/transform - Remove pixel re-exports of core modules (no backwards compat needed yet) - Add SESSION_MAX_AGE constant to core config - Pixel now imports everything from @imtbl/audience-core Co-Authored-By: Claude Opus 4.6 --- .../{pixel => core}/src/attribution.test.ts | 0 .../{pixel => core}/src/attribution.ts | 0 packages/audience/core/src/config.ts | 1 + .../{pixel => core}/src/consent.test.ts | 7 ------ .../audience/{pixel => core}/src/consent.ts | 11 ++++----- packages/audience/core/src/index.ts | 10 ++++++++ .../{pixel => core}/src/session.test.ts | 15 +++++++----- .../audience/{pixel => core}/src/session.ts | 15 ++++++------ packages/audience/pixel/src/index.ts | 9 -------- packages/audience/pixel/src/pixel.test.ts | 23 +++++++++---------- packages/audience/pixel/src/pixel.ts | 7 +++--- 11 files changed, 47 insertions(+), 51 deletions(-) rename packages/audience/{pixel => core}/src/attribution.test.ts (100%) rename packages/audience/{pixel => core}/src/attribution.ts (100%) rename packages/audience/{pixel => core}/src/consent.test.ts (94%) rename packages/audience/{pixel => core}/src/consent.ts (88%) rename packages/audience/{pixel => core}/src/session.test.ts (81%) rename packages/audience/{pixel => core}/src/session.ts (65%) diff --git a/packages/audience/pixel/src/attribution.test.ts b/packages/audience/core/src/attribution.test.ts similarity index 100% rename from packages/audience/pixel/src/attribution.test.ts rename to packages/audience/core/src/attribution.test.ts diff --git a/packages/audience/pixel/src/attribution.ts b/packages/audience/core/src/attribution.ts similarity index 100% rename from packages/audience/pixel/src/attribution.ts rename to packages/audience/core/src/attribution.ts diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index df6dd0f552..f1f775f317 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -15,5 +15,6 @@ export const FLUSH_SIZE = 20; export const COOKIE_NAME = 'imtbl_anon_id'; export const SESSION_COOKIE = '_imtbl_sid'; export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years +export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment]; diff --git a/packages/audience/pixel/src/consent.test.ts b/packages/audience/core/src/consent.test.ts similarity index 94% rename from packages/audience/pixel/src/consent.test.ts rename to packages/audience/core/src/consent.test.ts index b29becfffe..db35b8dbaa 100644 --- a/packages/audience/pixel/src/consent.test.ts +++ b/packages/audience/core/src/consent.test.ts @@ -1,12 +1,5 @@ import { createConsentManager } from './consent'; -// Mock audience-core -jest.mock('@imtbl/audience-core', () => ({ - httpSend: jest.fn().mockResolvedValue(true), - CONSENT_PATH: '/v1/audience/tracking-consent', - getBaseUrl: jest.fn().mockReturnValue('https://api.dev.immutable.com'), -})); - // Mock fetch globally const mockFetch = jest.fn().mockResolvedValue({ ok: true }); global.fetch = mockFetch; diff --git a/packages/audience/pixel/src/consent.ts b/packages/audience/core/src/consent.ts similarity index 88% rename from packages/audience/pixel/src/consent.ts rename to packages/audience/core/src/consent.ts index 03b78f1e27..97a833be00 100644 --- a/packages/audience/pixel/src/consent.ts +++ b/packages/audience/core/src/consent.ts @@ -1,14 +1,13 @@ -import type { - ConsentLevel, Message, Environment, MessageQueue, -} from '@imtbl/audience-core'; -import { CONSENT_PATH, getBaseUrl } from '@imtbl/audience-core'; +import type { ConsentLevel, Message, Environment } from './types'; +import type { MessageQueue } from './queue'; +import { CONSENT_PATH, getBaseUrl } from './config'; export interface ConsentManager { level: ConsentLevel; setLevel(next: ConsentLevel): void; } -function detectDoNotTrack(): boolean { +export function detectDoNotTrack(): boolean { if (typeof navigator === 'undefined') return false; // DNT header if (navigator.doNotTrack === '1') return true; @@ -22,7 +21,7 @@ function detectDoNotTrack(): boolean { * * - Default level is `'none'` (no collection). * - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`. - * - On downgrade (e.g. full → anonymous), strips `userId` from queued messages. + * - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages. * - On downgrade to `'none'`, purges the queue entirely. * - Fires PUT to `/v1/audience/tracking-consent` on every state change. */ diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index 0ae370d0d8..ce67501090 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -32,6 +32,7 @@ export { FLUSH_SIZE, COOKIE_NAME, SESSION_COOKIE, + SESSION_MAX_AGE, } from './config'; export { generateId, getTimestamp, isBrowser } from './utils'; @@ -46,3 +47,12 @@ export { truncate, truncateSource, } from './validation'; + +export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; +export type { SessionResult } from './session'; + +export { collectAttribution, clearAttribution } from './attribution'; +export type { Attribution } from './attribution'; + +export { createConsentManager, detectDoNotTrack } from './consent'; +export type { ConsentManager } from './consent'; diff --git a/packages/audience/pixel/src/session.test.ts b/packages/audience/core/src/session.test.ts similarity index 81% rename from packages/audience/pixel/src/session.test.ts rename to packages/audience/core/src/session.test.ts index db306653dd..e0320ffdfd 100644 --- a/packages/audience/pixel/src/session.test.ts +++ b/packages/audience/core/src/session.test.ts @@ -1,15 +1,18 @@ import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; -const SESSION_COOKIE_NAME = '_imtbl_sid'; +const SESSION_COOKIE = '_imtbl_sid'; -// Mock audience-core cookie helpers +// Mock internal modules const mockGetCookie = jest.fn(); const mockSetCookie = jest.fn(); const mockGenerateId = jest.fn(); -jest.mock('@imtbl/audience-core', () => ({ +jest.mock('./cookie', () => ({ getCookie: (...args: unknown[]) => mockGetCookie(...args), setCookie: (...args: unknown[]) => mockSetCookie(...args), +})); + +jest.mock('./utils', () => ({ generateId: (...args: unknown[]) => mockGenerateId(...args), })); @@ -25,7 +28,7 @@ describe('getOrCreateSession', () => { const result = getOrCreateSession(); expect(result.sessionId).toBe('new-session-id'); expect(result.isNew).toBe(true); - expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, undefined); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined); }); it('returns existing session and refreshes expiry', () => { @@ -34,7 +37,7 @@ describe('getOrCreateSession', () => { const result = getOrCreateSession(); expect(result.sessionId).toBe('existing-sid'); expect(result.isNew).toBe(false); - expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'existing-sid', 1800, undefined); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined); expect(mockGenerateId).not.toHaveBeenCalled(); }); @@ -42,7 +45,7 @@ describe('getOrCreateSession', () => { mockGetCookie.mockReturnValue(undefined); getOrCreateSession('.example.com'); - expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, '.example.com'); + expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com'); }); }); diff --git a/packages/audience/pixel/src/session.ts b/packages/audience/core/src/session.ts similarity index 65% rename from packages/audience/pixel/src/session.ts rename to packages/audience/core/src/session.ts index ce8b29680b..df1dd18fd2 100644 --- a/packages/audience/pixel/src/session.ts +++ b/packages/audience/core/src/session.ts @@ -1,7 +1,6 @@ -import { getCookie, setCookie, generateId } from '@imtbl/audience-core'; - -const SESSION_COOKIE_NAME = '_imtbl_sid'; -const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds +import { getCookie, setCookie } from './cookie'; +import { generateId } from './utils'; +import { SESSION_COOKIE, SESSION_MAX_AGE } from './config'; export interface SessionResult { sessionId: string; @@ -15,15 +14,15 @@ export interface SessionResult { * Returns whether the session is new so the caller can fire a `session_start` event. */ export function getOrCreateSession(domain?: string): SessionResult { - const existing = getCookie(SESSION_COOKIE_NAME); + const existing = getCookie(SESSION_COOKIE); if (existing) { // Refresh the rolling expiry - setCookie(SESSION_COOKIE_NAME, existing, SESSION_MAX_AGE, domain); + setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain); return { sessionId: existing, isNew: false }; } const id = generateId(); - setCookie(SESSION_COOKIE_NAME, id, SESSION_MAX_AGE, domain); + setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain); return { sessionId: id, isNew: true }; } @@ -33,5 +32,5 @@ export function getOrCreateSessionId(domain?: string): string { } export function getSessionId(): string | undefined { - return getCookie(SESSION_COOKIE_NAME); + return getCookie(SESSION_COOKIE); } diff --git a/packages/audience/pixel/src/index.ts b/packages/audience/pixel/src/index.ts index 042b78ea71..9deb78e510 100644 --- a/packages/audience/pixel/src/index.ts +++ b/packages/audience/pixel/src/index.ts @@ -1,15 +1,6 @@ export { Pixel } from './pixel'; export type { PixelInitOptions } from './pixel'; -export { createConsentManager } from './consent'; -export type { ConsentManager } from './consent'; - -export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; -export type { SessionResult } from './session'; - -export { collectAttribution, clearAttribution } from './attribution'; -export type { Attribution } from './attribution'; - export { createLoader } from './loader'; export type { Command, ImtblGlobal } from './loader'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index 3a3a795715..ee03ae462e 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -6,6 +6,7 @@ const mockStart = jest.fn(); const mockDestroy = jest.fn(); const mockPurge = jest.fn(); const mockTransform = jest.fn(); +const mockGetOrCreateSession = jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true }); jest.mock('@imtbl/audience-core', () => ({ MessageQueue: jest.fn().mockImplementation(() => ({ @@ -38,27 +39,25 @@ jest.mock('@imtbl/audience-core', () => ({ isBrowser: jest.fn().mockReturnValue(true), getCookie: jest.fn(), setCookie: jest.fn(), -})); - -// Mock internal modules -jest.mock('./attribution', () => ({ collectAttribution: jest.fn().mockReturnValue({ utm_source: 'google', landing_page: 'https://example.com', }), -})); - -jest.mock('./session', () => ({ - getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true }), - getOrCreateSessionId: jest.fn().mockReturnValue('session-abc'), + getOrCreateSession: (...args: unknown[]) => mockGetOrCreateSession(...args), + createConsentManager: jest.fn().mockImplementation( + (_queue: unknown, _key: unknown, _anonId: unknown, _env: unknown, level?: string) => { + let current = level ?? 'none'; + return { + get level() { return current; }, + setLevel(next: string) { current = next; }, + }; + }, + ), })); // Mock fetch globally global.fetch = jest.fn().mockResolvedValue({ ok: true }); -// Access the mock to change return values per test -const mockGetOrCreateSession = jest.requireMock('./session').getOrCreateSession; - let activePixel: Pixel | null = null; beforeEach(() => { diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index 61bcddd4a3..7b187885c8 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -5,6 +5,7 @@ import type { TrackMessage, IdentifyMessage, UserTraits, + ConsentManager, } from '@imtbl/audience-core'; import { MessageQueue, @@ -18,10 +19,10 @@ import { generateId, getTimestamp, isBrowser, + collectAttribution, + getOrCreateSession, + createConsentManager, } from '@imtbl/audience-core'; -import { collectAttribution } from './attribution'; -import { getOrCreateSession } from './session'; -import { createConsentManager, ConsentManager } from './consent'; const PIXEL_VERSION = '0.0.0'; From 4b3ad9ca3d0a780ff613c294f1d792d6b8f8779b Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 15:56:31 +1000 Subject: [PATCH 6/7] feat(audience): bump pixel to v1.0.0 and inject version at build time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read version from package.json in tsup.config.ts and inject it via esbuild define as PIXEL_VERSION_INJECTED. This stamps every event payload with the correct libraryVersion automatically — no manual bumping needed. Co-Authored-By: Claude Opus 4.6 --- packages/audience/pixel/package.json | 2 +- packages/audience/pixel/src/globals.d.ts | 2 ++ packages/audience/pixel/src/pixel.ts | 6 +++++- packages/audience/pixel/tsup.config.ts | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 packages/audience/pixel/src/globals.d.ts diff --git a/packages/audience/pixel/package.json b/packages/audience/pixel/package.json index 65e18d73ee..1c483beb1c 100644 --- a/packages/audience/pixel/package.json +++ b/packages/audience/pixel/package.json @@ -1,7 +1,7 @@ { "name": "@imtbl/pixel", "description": "Immutable Tracking Pixel — drop-in JavaScript snippet for device fingerprint, page view, and attribution data", - "version": "0.0.0", + "version": "1.0.0", "author": "Immutable", "private": true, "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", diff --git a/packages/audience/pixel/src/globals.d.ts b/packages/audience/pixel/src/globals.d.ts new file mode 100644 index 0000000000..778b446e67 --- /dev/null +++ b/packages/audience/pixel/src/globals.d.ts @@ -0,0 +1,2 @@ +/** Injected at build time by tsup `define` — see tsup.config.ts */ +declare const PIXEL_VERSION_INJECTED: string; diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index 7b187885c8..df5fa95413 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -24,7 +24,11 @@ import { createConsentManager, } from '@imtbl/audience-core'; -const PIXEL_VERSION = '0.0.0'; +// Replaced at build time by tsup `define` (see tsup.config.ts). +// In tests the global isn't defined, so we fall back to 'unknown'. +const PIXEL_VERSION: string = typeof PIXEL_VERSION_INJECTED !== 'undefined' + ? PIXEL_VERSION_INJECTED + : 'unknown'; export interface PixelInitOptions { key: string; diff --git a/packages/audience/pixel/tsup.config.ts b/packages/audience/pixel/tsup.config.ts index c1171ccca7..6cc4c7459b 100644 --- a/packages/audience/pixel/tsup.config.ts +++ b/packages/audience/pixel/tsup.config.ts @@ -1,6 +1,9 @@ import { resolve } from 'path'; +import { readFileSync } from 'fs'; import { defineConfig } from 'tsup'; +const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); + export default defineConfig({ entry: ['src/index.ts'], outDir: 'dist', @@ -14,6 +17,9 @@ export default defineConfig({ sourcemap: false, clean: true, noExternal: ['@imtbl/audience-core', '@imtbl/metrics'], + define: { + PIXEL_VERSION_INJECTED: JSON.stringify(pkg.version), + }, outExtension: () => ({ js: '.js' }), esbuildOptions(options) { options.outbase = 'src'; From dc937b3fe32e13783ede8b1e556ce567c916c809 Mon Sep 17 00:00:00 2001 From: Ben Booth Date: Tue, 7 Apr 2026 16:32:16 +1000 Subject: [PATCH 7/7] feat(audience): wire loader to pixel, add GA/Meta IDs, fix unload ordering Bootstrap wiring: - Add bootstrap.ts that maps command names to Pixel methods (init, page, identify, consent) and calls createLoader() to install on window.__imtbl with command replay - Add iife.ts as dedicated CDN entry point (excludes snippet generator and createLoader export, saving ~500 bytes) Third-party identity signals: - Read _ga (GA Client ID), _fbc (Meta click ID), _fbp (Meta browser ID) cookies and include in page view properties when present Bug fix: - Register session_end listeners BEFORE queue.start() so that on page unload, session_end is enqueued before the queue's flushUnload fires (DOM listeners fire in registration order) Bundle: 9.23 KB raw / 3.35 KB gzipped Co-Authored-By: Claude Opus 4.6 --- packages/audience/pixel/src/bootstrap.test.ts | 112 ++++++++++++++++++ packages/audience/pixel/src/bootstrap.ts | 41 +++++++ packages/audience/pixel/src/iife.ts | 8 ++ packages/audience/pixel/src/pixel.test.ts | 43 +++++++ packages/audience/pixel/src/pixel.ts | 31 ++++- packages/audience/pixel/tsup.config.ts | 2 +- 6 files changed, 233 insertions(+), 4 deletions(-) create mode 100644 packages/audience/pixel/src/bootstrap.test.ts create mode 100644 packages/audience/pixel/src/bootstrap.ts create mode 100644 packages/audience/pixel/src/iife.ts diff --git a/packages/audience/pixel/src/bootstrap.test.ts b/packages/audience/pixel/src/bootstrap.test.ts new file mode 100644 index 0000000000..f811564609 --- /dev/null +++ b/packages/audience/pixel/src/bootstrap.test.ts @@ -0,0 +1,112 @@ +/* eslint-disable @typescript-eslint/no-require-imports, global-require */ + +// We need to control when bootstrap.ts runs, so we use dynamic require +// after setting up the window stub and mocks. + +const mockInit = jest.fn(); +const mockPage = jest.fn(); +const mockIdentify = jest.fn(); +const mockSetConsent = jest.fn(); +const mockDestroy = jest.fn(); + +jest.mock('./pixel', () => ({ + Pixel: jest.fn().mockImplementation(() => ({ + init: mockInit, + page: mockPage, + identify: mockIdentify, + setConsent: mockSetConsent, + destroy: mockDestroy, + })), +})); + +jest.mock('@imtbl/audience-core', () => ({ + MessageQueue: jest.fn(), + httpTransport: {}, + getBaseUrl: jest.fn(), + INGEST_PATH: '', + FLUSH_INTERVAL_MS: 5000, + FLUSH_SIZE: 20, + getOrCreateAnonymousId: jest.fn(), + collectContext: jest.fn(), + generateId: jest.fn(), + getTimestamp: jest.fn(), + isBrowser: jest.fn().mockReturnValue(true), + getCookie: jest.fn(), + setCookie: jest.fn(), + collectAttribution: jest.fn().mockReturnValue({}), + getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 's', isNew: false }), + createConsentManager: jest.fn().mockReturnValue({ level: 'none', setLevel: jest.fn() }), +})); + +beforeEach(() => { + jest.clearAllMocks(); + delete (window as Record).__imtbl; + // Re-isolate module so the side-effect runs fresh + jest.resetModules(); +}); + +describe('bootstrap', () => { + it('replays queued init command from snippet stub', () => { + // Simulate snippet having queued an init command + (window as Record).__imtbl = [ + ['init', { key: 'pk_test', environment: 'dev', consent: 'anonymous' }], + ]; + + require('./bootstrap'); + + expect(mockInit).toHaveBeenCalledWith({ + key: 'pk_test', + environment: 'dev', + consent: 'anonymous', + }); + }); + + it('replays multiple queued commands in order', () => { + (window as Record).__imtbl = [ + ['init', { key: 'pk_test' }], + ['consent', 'full'], + ['identify', 'user-1', { email: 'a@b.com' }], + ]; + + require('./bootstrap'); + + expect(mockInit).toHaveBeenCalledWith({ key: 'pk_test' }); + expect(mockSetConsent).toHaveBeenCalledWith('full'); + expect(mockIdentify).toHaveBeenCalledWith('user-1', { email: 'a@b.com' }); + }); + + it('installs loader and handles new commands after load', () => { + require('./bootstrap'); + + const loader = (window as Record).__imtbl as { + push: (...args: unknown[]) => void; + _loaded: boolean; + }; + + expect(loader._loaded).toBe(true); + + loader.push(['page', { custom: 'prop' }]); + expect(mockPage).toHaveBeenCalledWith({ custom: 'prop' }); + + loader.push(['consent', 'anonymous']); + expect(mockSetConsent).toHaveBeenCalledWith('anonymous'); + }); + + it('ignores unknown commands', () => { + (window as Record).__imtbl = [ + ['nonexistent', 'arg1'], + ]; + + // Should not throw + expect(() => require('./bootstrap')).not.toThrow(); + }); + + it('works when no stub exists on window', () => { + expect(() => require('./bootstrap')).not.toThrow(); + + const loader = (window as Record).__imtbl as { + _loaded: boolean; + }; + expect(loader._loaded).toBe(true); + }); +}); diff --git a/packages/audience/pixel/src/bootstrap.ts b/packages/audience/pixel/src/bootstrap.ts new file mode 100644 index 0000000000..4d13470f7c --- /dev/null +++ b/packages/audience/pixel/src/bootstrap.ts @@ -0,0 +1,41 @@ +/** + * Self-executing bootstrap that wires the command-queue loader to the Pixel. + * + * When the IIFE bundle loads, this module: + * 1. Creates a Pixel singleton + * 2. Maps command names to Pixel methods + * 3. Installs the loader on window.__imtbl (replacing the stub array) + * 4. Replays any commands the snippet queued before the script loaded + */ +import { Pixel } from './pixel'; +import { createLoader } from './loader'; +import type { Command } from './loader'; + +const pixel = new Pixel(); + +function handleCommand(command: Command): void { + const [name, ...args] = command; + + switch (name) { + case 'init': + pixel.init(args[0] as Parameters[0]); + break; + case 'page': + pixel.page(args[0] as Parameters[0]); + break; + case 'identify': + pixel.identify( + args[0] as string, + args[1] as Parameters[1], + ); + break; + case 'consent': + pixel.setConsent(args[0] as Parameters[0]); + break; + default: + // Unknown command — ignore silently + break; + } +} + +createLoader(handleCommand); diff --git a/packages/audience/pixel/src/iife.ts b/packages/audience/pixel/src/iife.ts new file mode 100644 index 0000000000..e305eb2250 --- /dev/null +++ b/packages/audience/pixel/src/iife.ts @@ -0,0 +1,8 @@ +/** + * IIFE entry point for the CDN bundle (dist/imtbl.js). + * + * Only imports the bootstrap side-effect — no development utilities + * like snippet generator or createLoader are exposed, keeping the + * bundle as small as possible. + */ +import './bootstrap'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index ee03ae462e..74a7b8ea08 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -58,6 +58,8 @@ jest.mock('@imtbl/audience-core', () => ({ // Mock fetch globally global.fetch = jest.fn().mockResolvedValue({ ok: true }); +const mockGetCookie = jest.requireMock('@imtbl/audience-core').getCookie as jest.Mock; + let activePixel: Pixel | null = null; beforeEach(() => { @@ -166,6 +168,47 @@ describe('Pixel', () => { pixel.page(); expect(mockEnqueue).not.toHaveBeenCalled(); }); + + it('includes GA and Meta cookies in page properties when present', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + mockGetCookie.mockImplementation((name: string) => { + const cookies: Record = { + _ga: 'GA1.2.123456.789012', + _fbc: 'fb.1.1234567890.AbCdEf', + _fbp: 'fb.1.1234567890.987654321', + }; + return cookies[name]; + }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const props = pageCall!.properties as Record; + + expect(props.gaClientId).toBe('GA1.2.123456.789012'); + expect(props.fbClickId).toBe('fb.1.1234567890.AbCdEf'); + expect(props.fbBrowserId).toBe('fb.1.1234567890.987654321'); + }); + + it('omits third-party IDs when cookies are not set', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false }); + mockGetCookie.mockReturnValue(undefined); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const props = pageCall!.properties as Record; + + expect(props.gaClientId).toBeUndefined(); + expect(props.fbClickId).toBeUndefined(); + expect(props.fbBrowserId).toBeUndefined(); + }); }); describe('identify', () => { diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index df5fa95413..d0e3ce19dc 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -19,6 +19,7 @@ import { generateId, getTimestamp, isBrowser, + getCookie, collectAttribution, getOrCreateSession, createConsentManager, @@ -95,15 +96,18 @@ export class Pixel { consentLevel, ); - this.queue.start(); this.initialized = true; + // Register session_end listener BEFORE starting the queue so that + // on page unload, session_end is enqueued before the queue flushes. + // DOM event listeners fire in registration order. + this.registerSessionEnd(); + this.queue.start(); + // Auto-fire page view if consent allows if (this.consent.level !== 'none') { this.page(); } - - this.registerSessionEnd(); } page(properties?: Record): void { @@ -112,6 +116,7 @@ export class Pixel { const { sessionId, isNew } = getOrCreateSession(this.domain); this.refreshSession(sessionId, isNew); const attribution = collectAttribution(); + const thirdPartyIds = this.collectThirdPartyIds(); const context = collectContext('@imtbl/pixel', PIXEL_VERSION); const message: PageMessage = { @@ -123,6 +128,7 @@ export class Pixel { context, properties: { ...attribution, + ...thirdPartyIds, sessionId, ...properties, }, @@ -247,6 +253,25 @@ export class Pixel { } } + // -- Third-party identity signals ---------------------------------------- + + /** + * Read GA Client ID and Meta Pixel cookies when present. + * These are set by Google Analytics / Meta Pixel scripts and allow + * cross-platform identity stitching without requiring full consent. + */ + // eslint-disable-next-line class-methods-use-this + private collectThirdPartyIds(): Record { + const ids: Record = {}; + const ga = getCookie('_ga'); + if (ga) ids.gaClientId = ga; + const fbc = getCookie('_fbc'); + if (fbc) ids.fbClickId = fbc; + const fbp = getCookie('_fbp'); + if (fbp) ids.fbBrowserId = fbp; + return ids; + } + // -- Guards ------------------------------------------------------------- private canTrack(): boolean { diff --git a/packages/audience/pixel/tsup.config.ts b/packages/audience/pixel/tsup.config.ts index 6cc4c7459b..5b56d10709 100644 --- a/packages/audience/pixel/tsup.config.ts +++ b/packages/audience/pixel/tsup.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'tsup'; const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8')); export default defineConfig({ - entry: ['src/index.ts'], + entry: ['src/iife.ts'], outDir: 'dist', format: ['iife'], globalName: '__imtblPixelInternal',