diff --git a/packages/audience/core/src/config.test.ts b/packages/audience/core/src/config.test.ts deleted file mode 100644 index f1ef56c1ff..0000000000 --- a/packages/audience/core/src/config.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { getBaseUrl } from './config'; - -describe('getBaseUrl', () => { - it('returns sandbox URL for test keys', () => { - expect(getBaseUrl('pk_imapik-test-local')).toBe('https://api.sandbox.immutable.com'); - }); - - it('returns production URL for live keys', () => { - expect(getBaseUrl('pk_imapik-abcdef123')).toBe('https://api.immutable.com'); - }); - - it('returns production URL for empty key', () => { - expect(getBaseUrl('')).toBe('https://api.immutable.com'); - }); -}); diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index 2105170d3e..d125edacf8 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -1,4 +1,4 @@ -const TEST_KEY_PREFIX = 'pk_imapik-test-'; +export const BASE_URL = 'https://api.immutable.com'; export const INGEST_PATH = '/v1/audience/messages'; export const CONSENT_PATH = '/v1/audience/tracking-consent'; @@ -13,9 +13,3 @@ export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds export const SESSION_START = 'session_start'; export const SESSION_END = 'session_end'; - -export const getBaseUrl = (publishableKey: string): string => ( - publishableKey.startsWith(TEST_KEY_PREFIX) - ? 'https://api.sandbox.immutable.com' - : 'https://api.immutable.com' -); diff --git a/packages/audience/core/src/consent.test.ts b/packages/audience/core/src/consent.test.ts index 4d5667d556..8c581e74f4 100644 --- a/packages/audience/core/src/consent.test.ts +++ b/packages/audience/core/src/consent.test.ts @@ -110,7 +110,7 @@ describe('createConsentManager', () => { manager.setLevel('anonymous'); expect(send).toHaveBeenCalledWith( - 'https://api.sandbox.immutable.com/v1/audience/tracking-consent', + 'https://api.immutable.com/v1/audience/tracking-consent', 'pk_imapik-test-local', { anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' }, { method: 'PUT', keepalive: true }, @@ -146,7 +146,7 @@ describe('createConsentManager', () => { ok: false, error: new TransportError({ status: 503, - endpoint: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent', + endpoint: 'https://api.immutable.com/v1/audience/tracking-consent', body: { code: 'SERVICE_UNAVAILABLE' }, }), }); @@ -172,7 +172,7 @@ describe('createConsentManager', () => { ok: false, error: new TransportError({ status: 0, - endpoint: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent', + endpoint: 'https://api.immutable.com/v1/audience/tracking-consent', cause: new TypeError('Failed to fetch'), }), }); diff --git a/packages/audience/core/src/consent.ts b/packages/audience/core/src/consent.ts index 53f78fdc49..3027591b5c 100644 --- a/packages/audience/core/src/consent.ts +++ b/packages/audience/core/src/consent.ts @@ -4,7 +4,7 @@ import type { import type { MessageQueue } from './queue'; import type { HttpSend } from './transport'; import { type AudienceError, invokeOnError, toAudienceError } from './errors'; -import { CONSENT_PATH, getBaseUrl } from './config'; +import { BASE_URL, CONSENT_PATH } from './config'; export interface ConsentManager { level: ConsentLevel; @@ -59,7 +59,7 @@ export function createConsentManager( const LEVELS: Record = { none: 0, anonymous: 1, full: 2 }; function notifyBackend(level: ConsentLevel): void { - const url = `${baseUrl ?? getBaseUrl(publishableKey)}${CONSENT_PATH}`; + const url = `${baseUrl ?? BASE_URL}${CONSENT_PATH}`; const payload: ConsentUpdatePayload = { anonymousId, status: level, source }; // Fire-and-forget. HttpSend never rejects, so the floating chain is safe. send(url, publishableKey, payload, { method: 'PUT', keepalive: true }) diff --git a/packages/audience/core/src/queue.test.ts b/packages/audience/core/src/queue.test.ts index 539d94a570..da9d515fe7 100644 --- a/packages/audience/core/src/queue.test.ts +++ b/packages/audience/core/src/queue.test.ts @@ -206,7 +206,7 @@ describe('MessageQueue', () => { const send = jest.fn, Parameters>().mockResolvedValue({ ok: false, error: new TransportError({ - status: 500, endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages', body: null, + status: 500, endpoint: 'https://api.immutable.com/v1/audience/messages', body: null, }), }); const queue = createQueue(send, { onError }); @@ -228,7 +228,7 @@ describe('MessageQueue', () => { ok: false, error: new TransportError({ status: 0, - endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages', + endpoint: 'https://api.immutable.com/v1/audience/messages', cause: new TypeError('Failed to fetch'), }), }); @@ -278,7 +278,7 @@ describe('MessageQueue', () => { ok: false, error: new TransportError({ status: 200, - endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages', + endpoint: 'https://api.immutable.com/v1/audience/messages', body: { accepted: 1, rejected: 1 }, }), }); @@ -342,7 +342,7 @@ describe('page-unload flush (keepalive)', () => { document.dispatchEvent(new Event('visibilitychange')); expect(send).toHaveBeenCalledWith( - 'https://api.sandbox.immutable.com/v1/audience/messages', + 'https://api.immutable.com/v1/audience/messages', 'pk_imapik-test-local', expect.objectContaining({ messages: expect.any(Array) }), { keepalive: true }, @@ -366,7 +366,7 @@ describe('page-unload flush (keepalive)', () => { window.dispatchEvent(new Event('pagehide')); expect(send).toHaveBeenCalledWith( - 'https://api.sandbox.immutable.com/v1/audience/messages', + 'https://api.immutable.com/v1/audience/messages', 'pk_imapik-test-local', expect.objectContaining({ messages: expect.any(Array) }), { keepalive: true }, diff --git a/packages/audience/core/src/queue.ts b/packages/audience/core/src/queue.ts index f5eb1a112c..7013182ca7 100644 --- a/packages/audience/core/src/queue.ts +++ b/packages/audience/core/src/queue.ts @@ -2,7 +2,7 @@ import type { Message, BatchPayload } from './types'; import type { HttpSend } from './transport'; import { type AudienceError, invokeOnError, toAudienceError } from './errors'; import { - getBaseUrl, INGEST_PATH, FLUSH_INTERVAL_MS, FLUSH_SIZE, + BASE_URL, INGEST_PATH, FLUSH_INTERVAL_MS, FLUSH_SIZE, } from './config'; import * as storage from './storage'; import { isBrowser } from './utils'; @@ -87,7 +87,7 @@ export class MessageQueue { private readonly publishableKey: string, options?: MessageQueueOptions, ) { - this.endpointUrl = `${options?.baseUrl ?? getBaseUrl(publishableKey)}${INGEST_PATH}`; + this.endpointUrl = `${options?.baseUrl ?? BASE_URL}${INGEST_PATH}`; this.flushIntervalMs = options?.flushIntervalMs ?? FLUSH_INTERVAL_MS; this.flushSize = options?.flushSize ?? FLUSH_SIZE; this.onFlush = options?.onFlush; diff --git a/packages/audience/core/src/types.ts b/packages/audience/core/src/types.ts index 9e9e39aff2..f26bedfcbb 100644 --- a/packages/audience/core/src/types.ts +++ b/packages/audience/core/src/types.ts @@ -28,6 +28,8 @@ interface BaseMessage { anonymousId: string; surface: Surface; context: EventContext; + /** Present when the SDK/pixel is initialised with testMode: true. */ + test?: true; } export interface TrackMessage extends BaseMessage { diff --git a/packages/audience/pixel/README.md b/packages/audience/pixel/README.md index 6ebd349f6b..d1ad7cb04c 100644 --- a/packages/audience/pixel/README.md +++ b/packages/audience/pixel/README.md @@ -1,4 +1,4 @@ -# @imtbl/pixel — Immutable Tracking Pixel +# @imtbl/pixel A drop-in JavaScript snippet that captures device signals, page views, and attribution data for Immutable's events pipeline. Use it to measure campaign performance and attribute player acquisition across your marketing sites, landing pages, and web shops. Zero configuration beyond a publishable key. @@ -21,7 +21,7 @@ document.head.appendChild(s); Replace `YOUR_PUBLISHABLE_KEY` with your project's publishable key from [Immutable Hub](https://hub.immutable.com/). -The script loads asynchronously and does not block page rendering. The default consent level is `none` — the pixel loads but does not collect until consent is explicitly set (see [Consent Modes](#consent-modes)). To start collecting anonymous device signals immediately, add `"consent":"anonymous"` to the init options: +The script loads asynchronously and does not block page rendering. The default consent level is `none`: the pixel loads but does not collect until consent is explicitly set (see [Consent Modes](#consent-modes)). To start collecting anonymous device signals immediately, add `"consent":"anonymous"` to the init options: ```diff - w[i].push(["init",{"key":"YOUR_PUBLISHABLE_KEY"}]); @@ -34,7 +34,7 @@ The `consent` option controls what the pixel collects. **Default is `none`** (no | Level | What's collected | Cookies set | Use case | |-------|-----------------|-------------|----------| -| `none` | Nothing — pixel loads but is inert | None | Before consent banner interaction | +| `none` | Nothing (pixel loads but is inert) | None | Before consent banner interaction | | `anonymous` | Device signals, attribution, page views, form submissions, link clicks (no PII) | `imtbl_anon_id`, `_imtbl_sid` | Anonymous analytics without PII | | `full` | Everything in `anonymous` + hashed email capture from form submissions (for identity matching) | `imtbl_anon_id`, `_imtbl_sid` | After explicit user consent for marketing/ads | @@ -47,12 +47,12 @@ If your site uses a Consent Management Platform (CMP), the pixel can auto-detect + w[i].push(["init",{"key":"YOUR_KEY","consentMode":"auto"}]); ``` -> **Note:** `consentMode` and `consent` are mutually exclusive — do not set both. +> **Note:** `consentMode` and `consent` are mutually exclusive. Do not set both. The pixel starts in `none` and checks for these CMP standards (in priority order): -1. [**Google Consent Mode v2**](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced) — reads `analytics_storage` and `ad_storage` from `window.dataLayer` -2. [**IAB TCF v2**](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md) — reads purpose consents via `window.__tcfapi` +1. [**Google Consent Mode v2**](https://developers.google.com/tag-platform/security/guides/consent?consentmode=advanced): reads `analytics_storage` and `ad_storage` from `window.dataLayer` +2. [**IAB TCF v2**](https://github.com/InteractiveAdvertisingBureau/GDPR-Transparency-and-Consent-Framework/blob/master/TCFv2/IAB%20Tech%20Lab%20-%20CMP%20API%20v2.md): reads purpose consents via `window.__tcfapi` Once a CMP is detected, the pixel upgrades consent automatically and continues listening for changes (e.g. when a user updates their cookie preferences). If no CMP is detected after ~2.5 seconds, the pixel remains in `none` silently (there is no failure callback). If your CMP may not be present on every page, push a manual fallback on your own timeout: @@ -67,7 +67,7 @@ setTimeout(function() { If you are not using `consentMode: 'auto'`, you can set consent manually at any time: ```javascript -// After cookie banner interaction — upgrade to full +// After cookie banner interaction, upgrade to full window.__imtbl.push(['consent', 'full']); // Or downgrade (purges PII from queue) @@ -106,12 +106,23 @@ document.head.appendChild(s); ``` +## Test mode + +Set `"testMode": true` in the init options to mark every event with a +top-level `test: true` flag. Test events still flow through the +production endpoint, but can be filtered out of production analytics. + +```diff +- w[i].push(["init",{"key":"YOUR_KEY","consent":"anonymous"}]); ++ w[i].push(["init",{"key":"YOUR_KEY","consent":"anonymous","testMode":true}]); +``` + ## Cookies | Cookie | Lifetime | Purpose | |--------|----------|---------| | `imtbl_anon_id` | 2 years | Anonymous device ID (shared with web SDK) | -| `_imtbl_sid` | 30 minutes (rolling) | Session ID — resets on inactivity | +| `_imtbl_sid` | 30 minutes (rolling) | Session ID (resets on inactivity) | Both cookies are first-party (`SameSite=Lax`, `Secure` on HTTPS). @@ -154,7 +165,7 @@ Note: the nonce covers the inline snippet only. The CDN-loaded script (`imtbl.js ## Documentation -- [Tracking Pixel](https://docs.immutable.com/docs/products/audience/tracking-pixel) — this package (setup, consent modes, auto-tracked events) -- [Web SDK](https://docs.immutable.com/docs/products/audience/web-sdk) — sibling `@imtbl/audience` package for typed event tracking and identity management -- [REST API](https://docs.immutable.com/docs/products/audience/rest-api) — backend reference for direct integration -- [Data dictionary](https://docs.immutable.com/docs/products/audience/data-dictionary) — predefined event names and property schemas +- [Tracking Pixel](https://docs.immutable.com/docs/products/audience/tracking-pixel): this package (setup, consent modes, auto-tracked events) +- [Web SDK](https://docs.immutable.com/docs/products/audience/web-sdk): sibling `@imtbl/audience` package for typed event tracking and identity management +- [REST API](https://docs.immutable.com/docs/products/audience/rest-api): backend reference for direct integration +- [Data dictionary](https://docs.immutable.com/docs/products/audience/data-dictionary): predefined event names and property schemas diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index 3acc92a6cf..dd477fe734 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -38,6 +38,8 @@ export interface PixelInitOptions { autocapture?: AutocaptureOptions; /** Override the default API base URL. */ baseUrl?: string; + /** When true, all events are marked test: true and can be filtered from production analytics. */ + testMode?: boolean; } export class Pixel { @@ -55,6 +57,8 @@ export class Pixel { private domain: string | undefined; + private testMode = false; + private initialized = false; private unloadHandler?: () => void; @@ -80,6 +84,7 @@ export class Pixel { this.publishableKey = key; this.domain = domain; + this.testMode = options.testMode ?? false; this.queue = new MessageQueue( httpSend, @@ -320,7 +325,6 @@ export class Pixel { // -- Helpers ------------------------------------------------------------ - // eslint-disable-next-line class-methods-use-this private buildBase() { return { messageId: generateId(), @@ -328,6 +332,7 @@ export class Pixel { anonymousId: this.anonymousId, surface: 'pixel' as const, context: collectContext('@imtbl/pixel', PIXEL_VERSION), + ...(this.testMode && { test: true as const }), }; } diff --git a/packages/audience/sdk-sample-app/index.html b/packages/audience/sdk-sample-app/index.html index cd325bedcd..138a852ece 100644 --- a/packages/audience/sdk-sample-app/index.html +++ b/packages/audience/sdk-sample-app/index.html @@ -4,7 +4,7 @@ + content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src https://api.dev.immutable.com https://api.immutable.com"> Immutable Audience SDK — Sample App @@ -58,7 +58,6 @@

@@ -78,6 +77,10 @@

Mirror SDK internal log output into the in-page event log below.

+
+ +

Mark all events with test: true so they can be filtered from production analytics.

+
diff --git a/packages/audience/sdk-sample-app/package.json b/packages/audience/sdk-sample-app/package.json index d8172d57bd..04ec7e0f32 100644 --- a/packages/audience/sdk-sample-app/package.json +++ b/packages/audience/sdk-sample-app/package.json @@ -1,6 +1,6 @@ { "name": "@imtbl/audience-sdk-sample-app", - "description": "Interactive sample app for @imtbl/audience. Exercises every public method on the Audience class, every typed track() event, and every reachable AudienceErrorCode against the sandbox backend.", + "description": "Interactive sample app for @imtbl/audience. Exercises every public method on the Audience class, every typed track() event, and every reachable AudienceErrorCode.", "version": "0.0.0", "author": "Immutable", "private": true, diff --git a/packages/audience/sdk-sample-app/sample-app.js b/packages/audience/sdk-sample-app/sample-app.js index 1a4a93a045..8df8e2d3be 100644 --- a/packages/audience/sdk-sample-app/sample-app.js +++ b/packages/audience/sdk-sample-app/sample-app.js @@ -223,6 +223,7 @@ ['pk', 'value', 'pk'], ['initial-consent', 'value', 'initialConsent'], ['debug', 'checked', 'debug'], + ['test-mode', 'checked', 'testMode'], ['cookie-domain', 'value', 'cookieDomain'], ['flush-interval', 'value', 'flushInterval'], ['flush-size', 'value', 'flushSize'], @@ -329,6 +330,7 @@ debug: $('debug').checked, onError: handleError, }; + if ($('test-mode').checked) config.testMode = true; var cd = $('cookie-domain').value.trim(); if (cd) config.cookieDomain = cd; config.baseUrl = $('environment').value; var fi = parseInt($('flush-interval').value, 10); if (!Number.isNaN(fi)) config.flushInterval = fi; @@ -417,6 +419,7 @@ log('INIT', { consent: config.consent, debug: config.debug, + testMode: config.testMode || false, cookieDomain: config.cookieDomain, flushInterval: config.flushInterval, flushSize: config.flushSize, @@ -545,7 +548,7 @@ }); $('environment').addEventListener('change', function () { updateStatus(); saveUiState(); }); $('initial-consent').addEventListener('change', function () { saveUiState(); updateStatus(); }); - ['debug', 'cookie-domain', 'flush-interval', 'flush-size'].forEach(function (id) { + ['debug', 'test-mode', 'cookie-domain', 'flush-interval', 'flush-size'].forEach(function (id) { var el = $(id); if (!el) return; el.addEventListener('input', saveUiState); diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index bb6b031dbb..a7fef1041c 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -60,6 +60,8 @@ export class Audience { private readonly cookieDomain?: string; + private readonly testMode: boolean; + private anonymousId: string; private sessionId: string | undefined; @@ -81,6 +83,7 @@ export class Audience { const consentSource = DEFAULT_CONSENT_SOURCE; this.cookieDomain = cookieDomain; + this.testMode = config.testMode ?? false; this.debug = new DebugLogger(config.debug ?? false); let isNewSession = false; @@ -173,6 +176,7 @@ export class Audience { anonymousId: this.anonymousId, surface: 'web' as const, context: collectContext(LIBRARY_NAME, LIBRARY_VERSION), + ...(this.testMode && { test: true as const }), }; } diff --git a/packages/audience/sdk/src/types.ts b/packages/audience/sdk/src/types.ts index 92a85f072c..54653042da 100644 --- a/packages/audience/sdk/src/types.ts +++ b/packages/audience/sdk/src/types.ts @@ -16,6 +16,8 @@ export interface AudienceConfig { flushSize?: number; /** Override the default API base URL. */ baseUrl?: string; + /** When true, all events are marked test: true and can be filtered from production analytics. */ + testMode?: boolean; /** * Called when the SDK fails to reach the backend. Receives a structured * {@link AudienceError} with a machine-readable `code` so studios can