Skip to content

Commit fc8b5e3

Browse files
bkboothclaude
andauthored
feat(audience): remove sandbox env routing, add testMode (SDK-357) (#2872)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent f0f4f43 commit fc8b5e3

14 files changed

Lines changed: 60 additions & 51 deletions

File tree

packages/audience/core/src/config.test.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.
Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const TEST_KEY_PREFIX = 'pk_imapik-test-';
1+
export const BASE_URL = 'https://api.immutable.com';
22

33
export const INGEST_PATH = '/v1/audience/messages';
44
export const CONSENT_PATH = '/v1/audience/tracking-consent';
@@ -13,9 +13,3 @@ export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds
1313

1414
export const SESSION_START = 'session_start';
1515
export const SESSION_END = 'session_end';
16-
17-
export const getBaseUrl = (publishableKey: string): string => (
18-
publishableKey.startsWith(TEST_KEY_PREFIX)
19-
? 'https://api.sandbox.immutable.com'
20-
: 'https://api.immutable.com'
21-
);

packages/audience/core/src/consent.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ describe('createConsentManager', () => {
110110
manager.setLevel('anonymous');
111111

112112
expect(send).toHaveBeenCalledWith(
113-
'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
113+
'https://api.immutable.com/v1/audience/tracking-consent',
114114
'pk_imapik-test-local',
115115
{ anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' },
116116
{ method: 'PUT', keepalive: true },
@@ -146,7 +146,7 @@ describe('createConsentManager', () => {
146146
ok: false,
147147
error: new TransportError({
148148
status: 503,
149-
endpoint: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
149+
endpoint: 'https://api.immutable.com/v1/audience/tracking-consent',
150150
body: { code: 'SERVICE_UNAVAILABLE' },
151151
}),
152152
});
@@ -172,7 +172,7 @@ describe('createConsentManager', () => {
172172
ok: false,
173173
error: new TransportError({
174174
status: 0,
175-
endpoint: 'https://api.sandbox.immutable.com/v1/audience/tracking-consent',
175+
endpoint: 'https://api.immutable.com/v1/audience/tracking-consent',
176176
cause: new TypeError('Failed to fetch'),
177177
}),
178178
});

packages/audience/core/src/consent.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type {
44
import type { MessageQueue } from './queue';
55
import type { HttpSend } from './transport';
66
import { type AudienceError, invokeOnError, toAudienceError } from './errors';
7-
import { CONSENT_PATH, getBaseUrl } from './config';
7+
import { BASE_URL, CONSENT_PATH } from './config';
88

99
export interface ConsentManager {
1010
level: ConsentLevel;
@@ -59,7 +59,7 @@ export function createConsentManager(
5959
const LEVELS: Record<ConsentLevel, number> = { none: 0, anonymous: 1, full: 2 };
6060

6161
function notifyBackend(level: ConsentLevel): void {
62-
const url = `${baseUrl ?? getBaseUrl(publishableKey)}${CONSENT_PATH}`;
62+
const url = `${baseUrl ?? BASE_URL}${CONSENT_PATH}`;
6363
const payload: ConsentUpdatePayload = { anonymousId, status: level, source };
6464
// Fire-and-forget. HttpSend never rejects, so the floating chain is safe.
6565
send(url, publishableKey, payload, { method: 'PUT', keepalive: true })

packages/audience/core/src/queue.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ describe('MessageQueue', () => {
206206
const send = jest.fn<ReturnType<HttpSend>, Parameters<HttpSend>>().mockResolvedValue({
207207
ok: false,
208208
error: new TransportError({
209-
status: 500, endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages', body: null,
209+
status: 500, endpoint: 'https://api.immutable.com/v1/audience/messages', body: null,
210210
}),
211211
});
212212
const queue = createQueue(send, { onError });
@@ -228,7 +228,7 @@ describe('MessageQueue', () => {
228228
ok: false,
229229
error: new TransportError({
230230
status: 0,
231-
endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages',
231+
endpoint: 'https://api.immutable.com/v1/audience/messages',
232232
cause: new TypeError('Failed to fetch'),
233233
}),
234234
});
@@ -278,7 +278,7 @@ describe('MessageQueue', () => {
278278
ok: false,
279279
error: new TransportError({
280280
status: 200,
281-
endpoint: 'https://api.sandbox.immutable.com/v1/audience/messages',
281+
endpoint: 'https://api.immutable.com/v1/audience/messages',
282282
body: { accepted: 1, rejected: 1 },
283283
}),
284284
});
@@ -342,7 +342,7 @@ describe('page-unload flush (keepalive)', () => {
342342
document.dispatchEvent(new Event('visibilitychange'));
343343

344344
expect(send).toHaveBeenCalledWith(
345-
'https://api.sandbox.immutable.com/v1/audience/messages',
345+
'https://api.immutable.com/v1/audience/messages',
346346
'pk_imapik-test-local',
347347
expect.objectContaining({ messages: expect.any(Array) }),
348348
{ keepalive: true },
@@ -366,7 +366,7 @@ describe('page-unload flush (keepalive)', () => {
366366
window.dispatchEvent(new Event('pagehide'));
367367

368368
expect(send).toHaveBeenCalledWith(
369-
'https://api.sandbox.immutable.com/v1/audience/messages',
369+
'https://api.immutable.com/v1/audience/messages',
370370
'pk_imapik-test-local',
371371
expect.objectContaining({ messages: expect.any(Array) }),
372372
{ keepalive: true },

packages/audience/core/src/queue.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Message, BatchPayload } from './types';
22
import type { HttpSend } from './transport';
33
import { type AudienceError, invokeOnError, toAudienceError } from './errors';
44
import {
5-
getBaseUrl, INGEST_PATH, FLUSH_INTERVAL_MS, FLUSH_SIZE,
5+
BASE_URL, INGEST_PATH, FLUSH_INTERVAL_MS, FLUSH_SIZE,
66
} from './config';
77
import * as storage from './storage';
88
import { isBrowser } from './utils';
@@ -87,7 +87,7 @@ export class MessageQueue {
8787
private readonly publishableKey: string,
8888
options?: MessageQueueOptions,
8989
) {
90-
this.endpointUrl = `${options?.baseUrl ?? getBaseUrl(publishableKey)}${INGEST_PATH}`;
90+
this.endpointUrl = `${options?.baseUrl ?? BASE_URL}${INGEST_PATH}`;
9191
this.flushIntervalMs = options?.flushIntervalMs ?? FLUSH_INTERVAL_MS;
9292
this.flushSize = options?.flushSize ?? FLUSH_SIZE;
9393
this.onFlush = options?.onFlush;

packages/audience/core/src/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ interface BaseMessage {
2828
anonymousId: string;
2929
surface: Surface;
3030
context: EventContext;
31+
/** Present when the SDK/pixel is initialised with testMode: true. */
32+
test?: true;
3133
}
3234

3335
export interface TrackMessage extends BaseMessage {

packages/audience/pixel/README.md

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# @imtbl/pixel — Immutable Tracking Pixel
1+
# @imtbl/pixel
22

33
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.
44

@@ -21,7 +21,7 @@ document.head.appendChild(s);
2121

2222
Replace `YOUR_PUBLISHABLE_KEY` with your project's publishable key from [Immutable Hub](https://hub.immutable.com/).
2323

24-
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:
24+
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:
2525

2626
```diff
2727
- w[i].push(["init",{"key":"YOUR_PUBLISHABLE_KEY"}]);
@@ -34,7 +34,7 @@ The `consent` option controls what the pixel collects. **Default is `none`** (no
3434

3535
| Level | What's collected | Cookies set | Use case |
3636
|-------|-----------------|-------------|----------|
37-
| `none` | Nothing pixel loads but is inert | None | Before consent banner interaction |
37+
| `none` | Nothing (pixel loads but is inert) | None | Before consent banner interaction |
3838
| `anonymous` | Device signals, attribution, page views, form submissions, link clicks (no PII) | `imtbl_anon_id`, `_imtbl_sid` | Anonymous analytics without PII |
3939
| `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 |
4040

@@ -47,12 +47,12 @@ If your site uses a Consent Management Platform (CMP), the pixel can auto-detect
4747
+ w[i].push(["init",{"key":"YOUR_KEY","consentMode":"auto"}]);
4848
```
4949

50-
> **Note:** `consentMode` and `consent` are mutually exclusive — do not set both.
50+
> **Note:** `consentMode` and `consent` are mutually exclusive. Do not set both.
5151
5252
The pixel starts in `none` and checks for these CMP standards (in priority order):
5353

54-
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`
55-
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`
54+
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`
55+
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`
5656

5757
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:
5858

@@ -67,7 +67,7 @@ setTimeout(function() {
6767
If you are not using `consentMode: 'auto'`, you can set consent manually at any time:
6868

6969
```javascript
70-
// After cookie banner interaction upgrade to full
70+
// After cookie banner interaction, upgrade to full
7171
window.__imtbl.push(['consent', 'full']);
7272

7373
// Or downgrade (purges PII from queue)
@@ -106,12 +106,23 @@ document.head.appendChild(s);
106106
</script>
107107
```
108108

109+
## Test mode
110+
111+
Set `"testMode": true` in the init options to mark every event with a
112+
top-level `test: true` flag. Test events still flow through the
113+
production endpoint, but can be filtered out of production analytics.
114+
115+
```diff
116+
- w[i].push(["init",{"key":"YOUR_KEY","consent":"anonymous"}]);
117+
+ w[i].push(["init",{"key":"YOUR_KEY","consent":"anonymous","testMode":true}]);
118+
```
119+
109120
## Cookies
110121

111122
| Cookie | Lifetime | Purpose |
112123
|--------|----------|---------|
113124
| `imtbl_anon_id` | 2 years | Anonymous device ID (shared with web SDK) |
114-
| `_imtbl_sid` | 30 minutes (rolling) | Session ID resets on inactivity |
125+
| `_imtbl_sid` | 30 minutes (rolling) | Session ID (resets on inactivity) |
115126

116127
Both cookies are first-party (`SameSite=Lax`, `Secure` on HTTPS).
117128

@@ -154,7 +165,7 @@ Note: the nonce covers the inline snippet only. The CDN-loaded script (`imtbl.js
154165

155166
## Documentation
156167

157-
- [Tracking Pixel](https://docs.immutable.com/docs/products/audience/tracking-pixel) this package (setup, consent modes, auto-tracked events)
158-
- [Web SDK](https://docs.immutable.com/docs/products/audience/web-sdk) sibling `@imtbl/audience` package for typed event tracking and identity management
159-
- [REST API](https://docs.immutable.com/docs/products/audience/rest-api) backend reference for direct integration
160-
- [Data dictionary](https://docs.immutable.com/docs/products/audience/data-dictionary) predefined event names and property schemas
168+
- [Tracking Pixel](https://docs.immutable.com/docs/products/audience/tracking-pixel): this package (setup, consent modes, auto-tracked events)
169+
- [Web SDK](https://docs.immutable.com/docs/products/audience/web-sdk): sibling `@imtbl/audience` package for typed event tracking and identity management
170+
- [REST API](https://docs.immutable.com/docs/products/audience/rest-api): backend reference for direct integration
171+
- [Data dictionary](https://docs.immutable.com/docs/products/audience/data-dictionary): predefined event names and property schemas

packages/audience/pixel/src/pixel.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ export interface PixelInitOptions {
3838
autocapture?: AutocaptureOptions;
3939
/** Override the default API base URL. */
4040
baseUrl?: string;
41+
/** When true, all events are marked test: true and can be filtered from production analytics. */
42+
testMode?: boolean;
4143
}
4244

4345
export class Pixel {
@@ -55,6 +57,8 @@ export class Pixel {
5557

5658
private domain: string | undefined;
5759

60+
private testMode = false;
61+
5862
private initialized = false;
5963

6064
private unloadHandler?: () => void;
@@ -80,6 +84,7 @@ export class Pixel {
8084

8185
this.publishableKey = key;
8286
this.domain = domain;
87+
this.testMode = options.testMode ?? false;
8388

8489
this.queue = new MessageQueue(
8590
httpSend,
@@ -320,14 +325,14 @@ export class Pixel {
320325

321326
// -- Helpers ------------------------------------------------------------
322327

323-
// eslint-disable-next-line class-methods-use-this
324328
private buildBase() {
325329
return {
326330
messageId: generateId(),
327331
eventTimestamp: getTimestamp(),
328332
anonymousId: this.anonymousId,
329333
surface: 'pixel' as const,
330334
context: collectContext('@imtbl/pixel', PIXEL_VERSION),
335+
...(this.testMode && { test: true as const }),
331336
};
332337
}
333338

packages/audience/sdk-sample-app/index.html

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
<meta charset="utf-8">
55
<meta name="viewport" content="width=device-width, initial-scale=1">
66
<meta http-equiv="Content-Security-Policy"
7-
content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src https://api.dev.immutable.com https://api.sandbox.immutable.com https://api.immutable.com">
7+
content="default-src 'self'; script-src 'self'; style-src 'self'; connect-src https://api.dev.immutable.com https://api.immutable.com">
88
<title>Immutable Audience SDK — Sample App</title>
99
<link rel="stylesheet" href="sample-app.css">
1010
</head>
@@ -58,7 +58,6 @@ <h1>
5858
<label for="environment">Environment</label>
5959
<select id="environment">
6060
<option value="https://api.dev.immutable.com" selected>Dev</option>
61-
<option value="https://api.sandbox.immutable.com">Sandbox</option>
6261
<option value="https://api.immutable.com">Production</option>
6362
</select>
6463
</div>
@@ -78,6 +77,10 @@ <h1>
7877
<label><input id="debug" type="checkbox" checked> debug</label>
7978
<p class="helper-text">Mirror SDK internal log output into the in-page event log below.</p>
8079
</div>
80+
<div class="field">
81+
<label><input id="test-mode" type="checkbox"> testMode</label>
82+
<p class="helper-text">Mark all events with <code>test: true</code> so they can be filtered from production analytics.</p>
83+
</div>
8184

8285
<div class="advanced-grid">
8386
<div class="field">

0 commit comments

Comments
 (0)