Skip to content

Commit e1bc2e5

Browse files
bkboothclaude
andcommitted
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 <noreply@anthropic.com>
1 parent b4c2c2f commit e1bc2e5

13 files changed

Lines changed: 715 additions & 32 deletions

File tree

packages/audience/pixel/jest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { Config } from 'jest';
33
const config: Config = {
44
roots: ['<rootDir>/src'],
55
moduleDirectories: ['node_modules', 'src'],
6+
moduleNameMapper: { '^@imtbl/(.*)$': '<rootDir>/../../../node_modules/@imtbl/$1/src' },
67
testEnvironment: 'jsdom',
78
transform: {
89
'^.+\\.(t|j)sx?$': '@swc/jest',

packages/audience/pixel/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
"author": "Immutable",
66
"private": true,
77
"bugs": "https://github.com/immutable/ts-immutable-sdk/issues",
8-
"dependencies": {},
8+
"dependencies": {
9+
"@imtbl/audience-core": "workspace:*"
10+
},
911
"devDependencies": {
1012
"@swc/core": "^1.4.2",
1113
"@swc/jest": "^0.2.37",
@@ -33,10 +35,10 @@
3335
"scripts": {
3436
"build": "pnpm transpile && pnpm typegen",
3537
"transpile": "tsup",
36-
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
38+
"typegen": "tsc --customConditions development --emitDeclarationOnly --outDir dist/types",
3739
"lint": "eslint ./src --ext .ts --max-warnings=0",
3840
"test": "jest --passWithNoTests",
39-
"typecheck": "tsc --customConditions default --noEmit"
41+
"typecheck": "tsc --customConditions development --noEmit"
4042
},
4143
"type": "module",
4244
"types": "./dist/types/index.d.ts"
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { createConsentManager } from './consent';
2+
3+
// Mock audience-core
4+
jest.mock('@imtbl/audience-core', () => ({
5+
httpSend: jest.fn().mockResolvedValue(true),
6+
CONSENT_PATH: '/v1/audience/tracking-consent',
7+
getBaseUrl: jest.fn().mockReturnValue('https://api.dev.immutable.com'),
8+
}));
9+
10+
// Mock fetch globally
11+
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
12+
global.fetch = mockFetch;
13+
14+
function createMockQueue() {
15+
return {
16+
purge: jest.fn(),
17+
transform: jest.fn(),
18+
enqueue: jest.fn(),
19+
flush: jest.fn(),
20+
flushUnload: jest.fn(),
21+
start: jest.fn(),
22+
stop: jest.fn(),
23+
destroy: jest.fn(),
24+
clear: jest.fn(),
25+
get length() { return 0; },
26+
} as any;
27+
}
28+
29+
beforeEach(() => {
30+
jest.clearAllMocks();
31+
});
32+
33+
describe('createConsentManager', () => {
34+
it('defaults to none when no initial level provided', () => {
35+
const queue = createMockQueue();
36+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev');
37+
expect(manager.level).toBe('none');
38+
});
39+
40+
it('uses the initial level when provided', () => {
41+
const queue = createMockQueue();
42+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'anonymous');
43+
expect(manager.level).toBe('anonymous');
44+
});
45+
46+
it('upgrades consent without modifying queue', () => {
47+
const queue = createMockQueue();
48+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'none');
49+
50+
manager.setLevel('anonymous');
51+
expect(manager.level).toBe('anonymous');
52+
expect(queue.purge).not.toHaveBeenCalled();
53+
expect(queue.transform).not.toHaveBeenCalled();
54+
});
55+
56+
it('purges queue on downgrade to none', () => {
57+
const queue = createMockQueue();
58+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'full');
59+
60+
manager.setLevel('none');
61+
expect(manager.level).toBe('none');
62+
expect(queue.purge).toHaveBeenCalledWith(expect.any(Function));
63+
64+
// Verify the purge predicate matches all messages
65+
const purgeFn = queue.purge.mock.calls[0][0];
66+
expect(purgeFn({ type: 'page' })).toBe(true);
67+
});
68+
69+
it('strips userId on downgrade from full to anonymous', () => {
70+
const queue = createMockQueue();
71+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'full');
72+
73+
manager.setLevel('anonymous');
74+
expect(manager.level).toBe('anonymous');
75+
expect(queue.transform).toHaveBeenCalledWith(expect.any(Function));
76+
77+
// Verify the transform strips userId
78+
const transformFn = queue.transform.mock.calls[0][0];
79+
const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' };
80+
const result = transformFn(withUserId);
81+
expect(result.userId).toBeUndefined();
82+
expect(result.anonymousId).toBe('a-1');
83+
});
84+
85+
it('fires PUT to consent endpoint on level change', () => {
86+
const queue = createMockQueue();
87+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'none');
88+
89+
manager.setLevel('anonymous');
90+
91+
expect(mockFetch).toHaveBeenCalledWith(
92+
'https://api.dev.immutable.com/v1/audience/tracking-consent',
93+
expect.objectContaining({
94+
method: 'PUT',
95+
headers: expect.objectContaining({
96+
'Content-Type': 'application/json',
97+
'x-immutable-publishable-key': 'pk_test',
98+
}),
99+
}),
100+
);
101+
});
102+
103+
it('does nothing when setting the same level', () => {
104+
const queue = createMockQueue();
105+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'anonymous');
106+
107+
manager.setLevel('anonymous');
108+
expect(queue.purge).not.toHaveBeenCalled();
109+
expect(queue.transform).not.toHaveBeenCalled();
110+
expect(mockFetch).not.toHaveBeenCalled();
111+
});
112+
113+
it('respects DNT by defaulting to none', () => {
114+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
115+
116+
const queue = createMockQueue();
117+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev');
118+
expect(manager.level).toBe('none');
119+
120+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
121+
});
122+
});
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import type {
2+
ConsentLevel, Message, Environment, MessageQueue,
3+
} from '@imtbl/audience-core';
4+
import { CONSENT_PATH, getBaseUrl } from '@imtbl/audience-core';
5+
6+
export interface ConsentManager {
7+
level: ConsentLevel;
8+
setLevel(next: ConsentLevel): void;
9+
}
10+
11+
function detectDoNotTrack(): boolean {
12+
if (typeof navigator === 'undefined') return false;
13+
// DNT header
14+
if (navigator.doNotTrack === '1') return true;
15+
// Global Privacy Control
16+
if ((navigator as unknown as Record<string, unknown>).globalPrivacyControl === true) return true;
17+
return false;
18+
}
19+
20+
/**
21+
* Create a consent state machine.
22+
*
23+
* - Default level is `'none'` (no collection).
24+
* - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`.
25+
* - On downgrade (e.g. full → anonymous), strips `userId` from queued messages.
26+
* - On downgrade to `'none'`, purges the queue entirely.
27+
* - Fires PUT to `/v1/audience/tracking-consent` on every state change.
28+
*/
29+
export function createConsentManager(
30+
queue: MessageQueue,
31+
publishableKey: string,
32+
anonymousId: string,
33+
environment: Environment,
34+
initialLevel?: ConsentLevel,
35+
): ConsentManager {
36+
const dntDetected = detectDoNotTrack();
37+
let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none');
38+
39+
const LEVELS: Record<ConsentLevel, number> = { none: 0, anonymous: 1, full: 2 };
40+
41+
function notifyBackend(level: ConsentLevel): void {
42+
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
43+
const payload = { anonymousId, consentLevel: level };
44+
fetch(url, {
45+
method: 'PUT',
46+
headers: {
47+
'Content-Type': 'application/json',
48+
'x-immutable-publishable-key': publishableKey,
49+
},
50+
body: JSON.stringify(payload),
51+
keepalive: true,
52+
}).catch(() => {});
53+
}
54+
55+
const manager: ConsentManager = {
56+
get level() {
57+
return current;
58+
},
59+
60+
setLevel(next: ConsentLevel): void {
61+
if (next === current) return;
62+
63+
const isDowngrade = LEVELS[next] < LEVELS[current];
64+
65+
if (isDowngrade) {
66+
if (next === 'none') {
67+
// Purge all queued messages
68+
queue.purge(() => true);
69+
} else if (next === 'anonymous') {
70+
// Strip userId from queued messages
71+
queue.transform((msg: Message) => {
72+
if ('userId' in msg) {
73+
const { userId, ...rest } = msg;
74+
return rest as Message;
75+
}
76+
return msg;
77+
});
78+
}
79+
}
80+
81+
current = next;
82+
notifyBackend(next);
83+
},
84+
};
85+
86+
return manager;
87+
}

packages/audience/pixel/src/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
1+
export { Pixel } from './pixel';
2+
export type { PixelInitOptions } from './pixel';
3+
4+
export { createConsentManager } from './consent';
5+
export type { ConsentManager } from './consent';
6+
7+
export { getOrCreateSessionId, getSessionId } from './session';
8+
19
export { collectAttribution, clearAttribution } from './attribution';
210
export type { Attribution } from './attribution';
311

0 commit comments

Comments
 (0)