Skip to content

Commit 80fcd63

Browse files
bkboothclaudeImmutableJeffrey
authored
feat(audience): add pixel core class, consent state machine, and session cookie (#2830)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: ImmutableJeffrey <jeffrey.wong@immutable.com>
1 parent ac24f3a commit 80fcd63

21 files changed

Lines changed: 947 additions & 22 deletions
File renamed without changes.
File renamed without changes.

packages/audience/core/src/config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,6 @@ export const FLUSH_SIZE = 20;
1515
export const COOKIE_NAME = 'imtbl_anon_id';
1616
export const SESSION_COOKIE = '_imtbl_sid';
1717
export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years
18+
export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds
1819

1920
export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment];
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { createConsentManager } from './consent';
2+
import { httpSend } from './transport';
3+
4+
jest.mock('./transport', () => ({
5+
httpSend: jest.fn().mockResolvedValue(true),
6+
}));
7+
8+
const mockHttpSend = httpSend as jest.MockedFunction<typeof httpSend>;
9+
10+
function createMockQueue() {
11+
return {
12+
purge: jest.fn(),
13+
transform: jest.fn(),
14+
enqueue: jest.fn(),
15+
flush: jest.fn(),
16+
flushUnload: jest.fn(),
17+
start: jest.fn(),
18+
stop: jest.fn(),
19+
destroy: jest.fn(),
20+
clear: jest.fn(),
21+
get length() { return 0; },
22+
} as any;
23+
}
24+
25+
beforeEach(() => {
26+
jest.clearAllMocks();
27+
});
28+
29+
describe('createConsentManager', () => {
30+
it('defaults to none when no initial level provided', () => {
31+
const queue = createMockQueue();
32+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
33+
expect(manager.level).toBe('none');
34+
});
35+
36+
it('uses the initial level when provided', () => {
37+
const queue = createMockQueue();
38+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
39+
expect(manager.level).toBe('anonymous');
40+
});
41+
42+
it('upgrades consent without modifying queue', () => {
43+
const queue = createMockQueue();
44+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');
45+
46+
manager.setLevel('anonymous');
47+
expect(manager.level).toBe('anonymous');
48+
expect(queue.purge).not.toHaveBeenCalled();
49+
expect(queue.transform).not.toHaveBeenCalled();
50+
});
51+
52+
it('purges queue on downgrade to none', () => {
53+
const queue = createMockQueue();
54+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');
55+
56+
manager.setLevel('none');
57+
expect(manager.level).toBe('none');
58+
expect(queue.purge).toHaveBeenCalledWith(expect.any(Function));
59+
60+
// Verify the purge predicate matches all messages
61+
const purgeFn = queue.purge.mock.calls[0][0];
62+
expect(purgeFn({ type: 'page' })).toBe(true);
63+
});
64+
65+
it('strips userId on downgrade from full to anonymous', () => {
66+
const queue = createMockQueue();
67+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full');
68+
69+
manager.setLevel('anonymous');
70+
expect(manager.level).toBe('anonymous');
71+
expect(queue.transform).toHaveBeenCalledWith(expect.any(Function));
72+
73+
// Verify the transform strips userId
74+
const transformFn = queue.transform.mock.calls[0][0];
75+
const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' };
76+
const result = transformFn(withUserId);
77+
expect(result.userId).toBeUndefined();
78+
expect(result.anonymousId).toBe('a-1');
79+
});
80+
81+
it('fires PUT to consent endpoint on level change', () => {
82+
const queue = createMockQueue();
83+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none');
84+
85+
manager.setLevel('anonymous');
86+
87+
expect(mockHttpSend).toHaveBeenCalledWith(
88+
'https://api.dev.immutable.com/v1/audience/tracking-consent',
89+
'pk_test',
90+
{ anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' },
91+
{ method: 'PUT', keepalive: true },
92+
);
93+
});
94+
95+
it('does nothing when setting the same level', () => {
96+
const queue = createMockQueue();
97+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous');
98+
99+
manager.setLevel('anonymous');
100+
expect(queue.purge).not.toHaveBeenCalled();
101+
expect(queue.transform).not.toHaveBeenCalled();
102+
expect(mockHttpSend).not.toHaveBeenCalled();
103+
});
104+
105+
it('respects DNT by defaulting to none', () => {
106+
Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true });
107+
108+
const queue = createMockQueue();
109+
const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel');
110+
expect(manager.level).toBe('none');
111+
112+
Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true });
113+
});
114+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import type {
2+
ConsentLevel, ConsentUpdatePayload, Message, Environment,
3+
} from './types';
4+
import type { MessageQueue } from './queue';
5+
import { CONSENT_PATH, getBaseUrl } from './config';
6+
import { httpSend } from './transport';
7+
8+
export interface ConsentManager {
9+
level: ConsentLevel;
10+
setLevel(next: ConsentLevel): void;
11+
}
12+
13+
export function detectDoNotTrack(): boolean {
14+
if (typeof navigator === 'undefined') return false;
15+
// DNT header
16+
if (navigator.doNotTrack === '1') return true;
17+
// Global Privacy Control
18+
if ((navigator as unknown as Record<string, unknown>).globalPrivacyControl === true) return true;
19+
return false;
20+
}
21+
22+
/**
23+
* Create a consent state machine.
24+
*
25+
* - Default level is `'none'` (no collection).
26+
* - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`.
27+
* - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages.
28+
* - On downgrade to `'none'`, purges the queue entirely.
29+
* - Fires PUT to `/v1/audience/tracking-consent` on every state change.
30+
*/
31+
export function createConsentManager(
32+
queue: MessageQueue,
33+
publishableKey: string,
34+
anonymousId: string,
35+
environment: Environment,
36+
source: string,
37+
initialLevel?: ConsentLevel,
38+
): ConsentManager {
39+
const dntDetected = detectDoNotTrack();
40+
let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none');
41+
42+
const LEVELS: Record<ConsentLevel, number> = { none: 0, anonymous: 1, full: 2 };
43+
44+
function notifyBackend(level: ConsentLevel): void {
45+
const url = `${getBaseUrl(environment)}${CONSENT_PATH}`;
46+
const payload: ConsentUpdatePayload = { anonymousId, status: level, source };
47+
httpSend(url, publishableKey, payload, { method: 'PUT', keepalive: true });
48+
}
49+
50+
const manager: ConsentManager = {
51+
get level() {
52+
return current;
53+
},
54+
55+
setLevel(next: ConsentLevel): void {
56+
if (next === current) return;
57+
58+
const isDowngrade = LEVELS[next] < LEVELS[current];
59+
60+
if (isDowngrade) {
61+
if (next === 'none') {
62+
// Purge all queued messages
63+
queue.purge(() => true);
64+
} else if (next === 'anonymous') {
65+
// Strip userId from queued messages
66+
queue.transform((msg: Message) => {
67+
if ('userId' in msg) {
68+
const { userId, ...rest } = msg;
69+
return rest as Message;
70+
}
71+
return msg;
72+
});
73+
}
74+
}
75+
76+
current = next;
77+
notifyBackend(next);
78+
},
79+
};
80+
81+
return manager;
82+
}

packages/audience/core/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type {
1313
BatchPayload,
1414
ConsentLevel,
1515
ConsentStatus,
16+
ConsentUpdatePayload,
1617
} from './types';
1718

1819
export {
@@ -32,11 +33,12 @@ export {
3233
FLUSH_SIZE,
3334
COOKIE_NAME,
3435
SESSION_COOKIE,
36+
SESSION_MAX_AGE,
3537
} from './config';
3638

3739
export { generateId, getTimestamp, isBrowser } from './utils';
3840

39-
export type { Transport } from './transport';
41+
export type { Transport, TransportOptions } from './transport';
4042
export { httpTransport, httpSend } from './transport';
4143
export { MessageQueue } from './queue';
4244
export { collectContext } from './context';
@@ -46,3 +48,12 @@ export {
4648
truncate,
4749
truncateSource,
4850
} from './validation';
51+
52+
export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
53+
export type { SessionResult } from './session';
54+
55+
export { collectAttribution, clearAttribution } from './attribution';
56+
export type { Attribution } from './attribution';
57+
58+
export { createConsentManager, detectDoNotTrack } from './consent';
59+
export type { ConsentManager } from './consent';
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
2+
3+
const SESSION_COOKIE = '_imtbl_sid';
4+
5+
// Mock internal modules
6+
const mockGetCookie = jest.fn();
7+
const mockSetCookie = jest.fn();
8+
const mockGenerateId = jest.fn();
9+
10+
jest.mock('./cookie', () => ({
11+
getCookie: (...args: unknown[]) => mockGetCookie(...args),
12+
setCookie: (...args: unknown[]) => mockSetCookie(...args),
13+
}));
14+
15+
jest.mock('./utils', () => ({
16+
generateId: (...args: unknown[]) => mockGenerateId(...args),
17+
}));
18+
19+
beforeEach(() => {
20+
jest.clearAllMocks();
21+
mockGenerateId.mockReturnValue('new-session-id');
22+
});
23+
24+
describe('getOrCreateSession', () => {
25+
it('creates a new session when no cookie exists', () => {
26+
mockGetCookie.mockReturnValue(undefined);
27+
28+
const result = getOrCreateSession();
29+
expect(result.sessionId).toBe('new-session-id');
30+
expect(result.isNew).toBe(true);
31+
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined);
32+
});
33+
34+
it('returns existing session and refreshes expiry', () => {
35+
mockGetCookie.mockReturnValue('existing-sid');
36+
37+
const result = getOrCreateSession();
38+
expect(result.sessionId).toBe('existing-sid');
39+
expect(result.isNew).toBe(false);
40+
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined);
41+
expect(mockGenerateId).not.toHaveBeenCalled();
42+
});
43+
44+
it('passes domain to setCookie', () => {
45+
mockGetCookie.mockReturnValue(undefined);
46+
47+
getOrCreateSession('.example.com');
48+
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com');
49+
});
50+
});
51+
52+
describe('getOrCreateSessionId', () => {
53+
it('returns the session ID string', () => {
54+
mockGetCookie.mockReturnValue(undefined);
55+
56+
const id = getOrCreateSessionId();
57+
expect(id).toBe('new-session-id');
58+
});
59+
60+
it('returns existing session ID', () => {
61+
mockGetCookie.mockReturnValue('existing-sid');
62+
expect(getOrCreateSessionId()).toBe('existing-sid');
63+
});
64+
});
65+
66+
describe('getSessionId', () => {
67+
it('returns the session ID from cookie', () => {
68+
mockGetCookie.mockReturnValue('existing-sid');
69+
expect(getSessionId()).toBe('existing-sid');
70+
});
71+
72+
it('returns undefined when no session cookie exists', () => {
73+
mockGetCookie.mockReturnValue(undefined);
74+
expect(getSessionId()).toBeUndefined();
75+
});
76+
});
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { getCookie, setCookie } from './cookie';
2+
import { generateId } from './utils';
3+
import { SESSION_COOKIE, SESSION_MAX_AGE } from './config';
4+
5+
export interface SessionResult {
6+
sessionId: string;
7+
isNew: boolean;
8+
}
9+
10+
/**
11+
* Get or create a session ID.
12+
*
13+
* The session cookie has a 30-minute rolling expiry — each call refreshes it.
14+
* Returns whether the session is new so the caller can fire a `session_start` event.
15+
*/
16+
export function getOrCreateSession(domain?: string): SessionResult {
17+
const existing = getCookie(SESSION_COOKIE);
18+
if (existing) {
19+
// Refresh the rolling expiry
20+
setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain);
21+
return { sessionId: existing, isNew: false };
22+
}
23+
24+
const id = generateId();
25+
setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain);
26+
return { sessionId: id, isNew: true };
27+
}
28+
29+
/** Convenience wrapper that returns just the session ID string. */
30+
export function getOrCreateSessionId(domain?: string): string {
31+
return getOrCreateSession(domain).sessionId;
32+
}
33+
34+
export function getSessionId(): string | undefined {
35+
return getCookie(SESSION_COOKIE);
36+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,20 @@ describe('httpSend', () => {
5151
}));
5252
});
5353

54+
it('uses specified method from options', async () => {
55+
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
56+
global.fetch = mockFetch;
57+
58+
const consentPayload = { anonymousId: 'anon-1', status: 'anonymous' as const, source: 'pixel' };
59+
await httpSend('https://example.com/consent', 'pk', consentPayload, { method: 'PUT', keepalive: true });
60+
61+
expect(mockFetch).toHaveBeenCalledWith('https://example.com/consent', expect.objectContaining({
62+
method: 'PUT',
63+
keepalive: true,
64+
body: JSON.stringify(consentPayload),
65+
}));
66+
});
67+
5468
it('returns true on success', async () => {
5569
global.fetch = jest.fn().mockResolvedValue({ ok: true });
5670
expect(await httpSend('https://example.com', 'pk', payload)).toBe(true);

packages/audience/core/src/transport.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { track, trackError } from '@imtbl/metrics';
2-
import type { BatchPayload } from './types';
2+
import type { BatchPayload, ConsentUpdatePayload } from './types';
33

44
export interface TransportOptions {
5+
method?: string;
56
keepalive?: boolean;
67
}
78

@@ -12,12 +13,12 @@ export interface Transport {
1213
export async function httpSend(
1314
url: string,
1415
publishableKey: string,
15-
payload: BatchPayload,
16+
payload: BatchPayload | ConsentUpdatePayload,
1617
options?: TransportOptions,
1718
): Promise<boolean> {
1819
try {
1920
const response = await fetch(url, {
20-
method: 'POST',
21+
method: options?.method ?? 'POST',
2122
headers: {
2223
'Content-Type': 'application/json',
2324
'x-immutable-publishable-key': publishableKey,

0 commit comments

Comments
 (0)