Skip to content

Commit 51a9008

Browse files
bkboothclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 938ea87 commit 51a9008

11 files changed

Lines changed: 47 additions & 51 deletions

File tree

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];

packages/audience/pixel/src/consent.test.ts renamed to packages/audience/core/src/consent.test.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,5 @@
11
import { createConsentManager } from './consent';
22

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-
103
// Mock fetch globally
114
const mockFetch = jest.fn().mockResolvedValue({ ok: true });
125
global.fetch = mockFetch;
Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
1-
import type {
2-
ConsentLevel, Message, Environment, MessageQueue,
3-
} from '@imtbl/audience-core';
4-
import { CONSENT_PATH, getBaseUrl } from '@imtbl/audience-core';
1+
import type { ConsentLevel, Message, Environment } from './types';
2+
import type { MessageQueue } from './queue';
3+
import { CONSENT_PATH, getBaseUrl } from './config';
54

65
export interface ConsentManager {
76
level: ConsentLevel;
87
setLevel(next: ConsentLevel): void;
98
}
109

11-
function detectDoNotTrack(): boolean {
10+
export function detectDoNotTrack(): boolean {
1211
if (typeof navigator === 'undefined') return false;
1312
// DNT header
1413
if (navigator.doNotTrack === '1') return true;
@@ -22,7 +21,7 @@ function detectDoNotTrack(): boolean {
2221
*
2322
* - Default level is `'none'` (no collection).
2423
* - 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.
24+
* - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages.
2625
* - On downgrade to `'none'`, purges the queue entirely.
2726
* - Fires PUT to `/v1/audience/tracking-consent` on every state change.
2827
*/

packages/audience/core/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export {
3232
FLUSH_SIZE,
3333
COOKIE_NAME,
3434
SESSION_COOKIE,
35+
SESSION_MAX_AGE,
3536
} from './config';
3637

3738
export { generateId, getTimestamp, isBrowser } from './utils';
@@ -46,3 +47,12 @@ export {
4647
truncate,
4748
truncateSource,
4849
} from './validation';
50+
51+
export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
52+
export type { SessionResult } from './session';
53+
54+
export { collectAttribution, clearAttribution } from './attribution';
55+
export type { Attribution } from './attribution';
56+
57+
export { createConsentManager, detectDoNotTrack } from './consent';
58+
export type { ConsentManager } from './consent';

packages/audience/pixel/src/session.test.ts renamed to packages/audience/core/src/session.test.ts

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
22

3-
const SESSION_COOKIE_NAME = '_imtbl_sid';
3+
const SESSION_COOKIE = '_imtbl_sid';
44

5-
// Mock audience-core cookie helpers
5+
// Mock internal modules
66
const mockGetCookie = jest.fn();
77
const mockSetCookie = jest.fn();
88
const mockGenerateId = jest.fn();
99

10-
jest.mock('@imtbl/audience-core', () => ({
10+
jest.mock('./cookie', () => ({
1111
getCookie: (...args: unknown[]) => mockGetCookie(...args),
1212
setCookie: (...args: unknown[]) => mockSetCookie(...args),
13+
}));
14+
15+
jest.mock('./utils', () => ({
1316
generateId: (...args: unknown[]) => mockGenerateId(...args),
1417
}));
1518

@@ -25,7 +28,7 @@ describe('getOrCreateSession', () => {
2528
const result = getOrCreateSession();
2629
expect(result.sessionId).toBe('new-session-id');
2730
expect(result.isNew).toBe(true);
28-
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, undefined);
31+
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined);
2932
});
3033

3134
it('returns existing session and refreshes expiry', () => {
@@ -34,15 +37,15 @@ describe('getOrCreateSession', () => {
3437
const result = getOrCreateSession();
3538
expect(result.sessionId).toBe('existing-sid');
3639
expect(result.isNew).toBe(false);
37-
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'existing-sid', 1800, undefined);
40+
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined);
3841
expect(mockGenerateId).not.toHaveBeenCalled();
3942
});
4043

4144
it('passes domain to setCookie', () => {
4245
mockGetCookie.mockReturnValue(undefined);
4346

4447
getOrCreateSession('.example.com');
45-
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE_NAME, 'new-session-id', 1800, '.example.com');
48+
expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com');
4649
});
4750
});
4851

Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { getCookie, setCookie, generateId } from '@imtbl/audience-core';
2-
3-
const SESSION_COOKIE_NAME = '_imtbl_sid';
4-
const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds
1+
import { getCookie, setCookie } from './cookie';
2+
import { generateId } from './utils';
3+
import { SESSION_COOKIE, SESSION_MAX_AGE } from './config';
54

65
export interface SessionResult {
76
sessionId: string;
@@ -15,15 +14,15 @@ export interface SessionResult {
1514
* Returns whether the session is new so the caller can fire a `session_start` event.
1615
*/
1716
export function getOrCreateSession(domain?: string): SessionResult {
18-
const existing = getCookie(SESSION_COOKIE_NAME);
17+
const existing = getCookie(SESSION_COOKIE);
1918
if (existing) {
2019
// Refresh the rolling expiry
21-
setCookie(SESSION_COOKIE_NAME, existing, SESSION_MAX_AGE, domain);
20+
setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain);
2221
return { sessionId: existing, isNew: false };
2322
}
2423

2524
const id = generateId();
26-
setCookie(SESSION_COOKIE_NAME, id, SESSION_MAX_AGE, domain);
25+
setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain);
2726
return { sessionId: id, isNew: true };
2827
}
2928

@@ -33,5 +32,5 @@ export function getOrCreateSessionId(domain?: string): string {
3332
}
3433

3534
export function getSessionId(): string | undefined {
36-
return getCookie(SESSION_COOKIE_NAME);
35+
return getCookie(SESSION_COOKIE);
3736
}

packages/audience/pixel/src/index.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,6 @@
11
export { Pixel } from './pixel';
22
export type { PixelInitOptions } from './pixel';
33

4-
export { createConsentManager } from './consent';
5-
export type { ConsentManager } from './consent';
6-
7-
export { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session';
8-
export type { SessionResult } from './session';
9-
10-
export { collectAttribution, clearAttribution } from './attribution';
11-
export type { Attribution } from './attribution';
12-
134
export { createLoader } from './loader';
145
export type { Command, ImtblGlobal } from './loader';
156

packages/audience/pixel/src/pixel.test.ts

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ const mockStart = jest.fn();
66
const mockDestroy = jest.fn();
77
const mockPurge = jest.fn();
88
const mockTransform = jest.fn();
9+
const mockGetOrCreateSession = jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true });
910

1011
jest.mock('@imtbl/audience-core', () => ({
1112
MessageQueue: jest.fn().mockImplementation(() => ({
@@ -38,27 +39,25 @@ jest.mock('@imtbl/audience-core', () => ({
3839
isBrowser: jest.fn().mockReturnValue(true),
3940
getCookie: jest.fn(),
4041
setCookie: jest.fn(),
41-
}));
42-
43-
// Mock internal modules
44-
jest.mock('./attribution', () => ({
4542
collectAttribution: jest.fn().mockReturnValue({
4643
utm_source: 'google',
4744
landing_page: 'https://example.com',
4845
}),
49-
}));
50-
51-
jest.mock('./session', () => ({
52-
getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 'session-abc', isNew: true }),
53-
getOrCreateSessionId: jest.fn().mockReturnValue('session-abc'),
46+
getOrCreateSession: (...args: unknown[]) => mockGetOrCreateSession(...args),
47+
createConsentManager: jest.fn().mockImplementation(
48+
(_queue: unknown, _key: unknown, _anonId: unknown, _env: unknown, level?: string) => {
49+
let current = level ?? 'none';
50+
return {
51+
get level() { return current; },
52+
setLevel(next: string) { current = next; },
53+
};
54+
},
55+
),
5456
}));
5557

5658
// Mock fetch globally
5759
global.fetch = jest.fn().mockResolvedValue({ ok: true });
5860

59-
// Access the mock to change return values per test
60-
const mockGetOrCreateSession = jest.requireMock('./session').getOrCreateSession;
61-
6261
let activePixel: Pixel | null = null;
6362

6463
beforeEach(() => {

0 commit comments

Comments
 (0)