-
Notifications
You must be signed in to change notification settings - Fork 38
feat(audience): add pixel core class, consent state machine, and session cookie #2830
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 10 commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
1f76423
feat(audience): scaffold @imtbl/pixel package with attribution, loade…
bkbooth b4c2c2f
Merge remote-tracking branch 'origin/main' into SDK-50-pixel-package-…
bkbooth 09736d7
fix(audience): add missing click IDs and attribution fields per event…
bkbooth 8f6bce0
feat(audience): add pixel core class, consent state machine, and sess…
bkbooth 938ea87
feat(audience): add session_start/end events and fix test isolation
bkbooth 51a9008
refactor(audience): lift session, attribution, and consent into core
bkbooth 4b3ad9c
feat(audience): bump pixel to v1.0.0 and inject version at build time
bkbooth 3d60f85
Merge remote-tracking branch 'origin/main' into SDK-50-pixel-core-con…
ImmutableJeffrey 6c90efc
Merge remote-tracking branch 'origin/main' into SDK-50-pixel-core-con…
bkbooth 4fdbd61
refactor(audience): address PR review — buildBase helper, consent pay…
bkbooth 46a42d8
refactor(audience): make httpSend generic for consent PUT
bkbooth File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
File renamed without changes.
File renamed without changes.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,116 @@ | ||
| import { createConsentManager } from './consent'; | ||
|
|
||
| // Mock fetch globally | ||
| const mockFetch = jest.fn().mockResolvedValue({ ok: true }); | ||
| global.fetch = mockFetch; | ||
|
|
||
| function createMockQueue() { | ||
| return { | ||
| purge: jest.fn(), | ||
| transform: jest.fn(), | ||
| enqueue: jest.fn(), | ||
| flush: jest.fn(), | ||
| flushUnload: jest.fn(), | ||
| start: jest.fn(), | ||
| stop: jest.fn(), | ||
| destroy: jest.fn(), | ||
| clear: jest.fn(), | ||
| get length() { return 0; }, | ||
| } as any; | ||
| } | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| }); | ||
|
|
||
| describe('createConsentManager', () => { | ||
| it('defaults to none when no initial level provided', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel'); | ||
| expect(manager.level).toBe('none'); | ||
| }); | ||
|
|
||
| it('uses the initial level when provided', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous'); | ||
| expect(manager.level).toBe('anonymous'); | ||
| }); | ||
|
|
||
| it('upgrades consent without modifying queue', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none'); | ||
|
|
||
| manager.setLevel('anonymous'); | ||
| expect(manager.level).toBe('anonymous'); | ||
| expect(queue.purge).not.toHaveBeenCalled(); | ||
| expect(queue.transform).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('purges queue on downgrade to none', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full'); | ||
|
|
||
| manager.setLevel('none'); | ||
| expect(manager.level).toBe('none'); | ||
| expect(queue.purge).toHaveBeenCalledWith(expect.any(Function)); | ||
|
|
||
| // Verify the purge predicate matches all messages | ||
| const purgeFn = queue.purge.mock.calls[0][0]; | ||
| expect(purgeFn({ type: 'page' })).toBe(true); | ||
| }); | ||
|
|
||
| it('strips userId on downgrade from full to anonymous', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'full'); | ||
|
|
||
| manager.setLevel('anonymous'); | ||
| expect(manager.level).toBe('anonymous'); | ||
| expect(queue.transform).toHaveBeenCalledWith(expect.any(Function)); | ||
|
|
||
| // Verify the transform strips userId | ||
| const transformFn = queue.transform.mock.calls[0][0]; | ||
| const withUserId = { type: 'page', userId: 'u-1', anonymousId: 'a-1' }; | ||
| const result = transformFn(withUserId); | ||
| expect(result.userId).toBeUndefined(); | ||
| expect(result.anonymousId).toBe('a-1'); | ||
| }); | ||
|
|
||
| it('fires PUT to consent endpoint on level change', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'none'); | ||
|
|
||
| manager.setLevel('anonymous'); | ||
|
|
||
| expect(mockFetch).toHaveBeenCalledWith( | ||
| 'https://api.dev.immutable.com/v1/audience/tracking-consent', | ||
| expect.objectContaining({ | ||
| method: 'PUT', | ||
| headers: expect.objectContaining({ | ||
| 'Content-Type': 'application/json', | ||
| 'x-immutable-publishable-key': 'pk_test', | ||
| }), | ||
| body: JSON.stringify({ anonymousId: 'anon-1', status: 'anonymous', source: 'pixel' }), | ||
| }), | ||
| ); | ||
| }); | ||
|
|
||
| it('does nothing when setting the same level', () => { | ||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel', 'anonymous'); | ||
|
|
||
| manager.setLevel('anonymous'); | ||
| expect(queue.purge).not.toHaveBeenCalled(); | ||
| expect(queue.transform).not.toHaveBeenCalled(); | ||
| expect(mockFetch).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('respects DNT by defaulting to none', () => { | ||
| Object.defineProperty(navigator, 'doNotTrack', { value: '1', configurable: true }); | ||
|
|
||
| const queue = createMockQueue(); | ||
| const manager = createConsentManager(queue, 'pk_test', 'anon-1', 'dev', 'pixel'); | ||
| expect(manager.level).toBe('none'); | ||
|
|
||
| Object.defineProperty(navigator, 'doNotTrack', { value: '0', configurable: true }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| import type { ConsentLevel, Message, Environment } from './types'; | ||
| import type { MessageQueue } from './queue'; | ||
| import { CONSENT_PATH, getBaseUrl } from './config'; | ||
|
|
||
| export interface ConsentManager { | ||
| level: ConsentLevel; | ||
| setLevel(next: ConsentLevel): void; | ||
| } | ||
|
|
||
| export function detectDoNotTrack(): boolean { | ||
| if (typeof navigator === 'undefined') return false; | ||
| // DNT header | ||
| if (navigator.doNotTrack === '1') return true; | ||
| // Global Privacy Control | ||
| if ((navigator as unknown as Record<string, unknown>).globalPrivacyControl === true) return true; | ||
| return false; | ||
| } | ||
|
|
||
| /** | ||
| * Create a consent state machine. | ||
| * | ||
| * - Default level is `'none'` (no collection). | ||
| * - If DNT or GPC is detected and no explicit consent is provided, stays `'none'`. | ||
| * - On downgrade (e.g. full -> anonymous), strips `userId` from queued messages. | ||
| * - On downgrade to `'none'`, purges the queue entirely. | ||
| * - Fires PUT to `/v1/audience/tracking-consent` on every state change. | ||
| */ | ||
| export function createConsentManager( | ||
| queue: MessageQueue, | ||
| publishableKey: string, | ||
| anonymousId: string, | ||
| environment: Environment, | ||
| source: string, | ||
| initialLevel?: ConsentLevel, | ||
| ): ConsentManager { | ||
| const dntDetected = detectDoNotTrack(); | ||
| let current: ConsentLevel = initialLevel ?? (dntDetected ? 'none' : 'none'); | ||
|
|
||
| const LEVELS: Record<ConsentLevel, number> = { none: 0, anonymous: 1, full: 2 }; | ||
|
|
||
| function notifyBackend(level: ConsentLevel): void { | ||
| const url = `${getBaseUrl(environment)}${CONSENT_PATH}`; | ||
| const payload = { anonymousId, status: level, source }; | ||
| // Uses fetch directly rather than httpSend because this is a PUT | ||
| // to a different endpoint with a different payload shape than the | ||
| // message ingest POST that httpSend is designed for. | ||
| fetch(url, { | ||
| method: 'PUT', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'x-immutable-publishable-key': publishableKey, | ||
| }, | ||
| body: JSON.stringify(payload), | ||
| keepalive: true, | ||
| }).catch(() => {}); | ||
| } | ||
|
|
||
| const manager: ConsentManager = { | ||
| get level() { | ||
| return current; | ||
| }, | ||
|
|
||
| setLevel(next: ConsentLevel): void { | ||
| if (next === current) return; | ||
|
|
||
| const isDowngrade = LEVELS[next] < LEVELS[current]; | ||
|
|
||
| if (isDowngrade) { | ||
| if (next === 'none') { | ||
| // Purge all queued messages | ||
| queue.purge(() => true); | ||
| } else if (next === 'anonymous') { | ||
| // Strip userId from queued messages | ||
| queue.transform((msg: Message) => { | ||
| if ('userId' in msg) { | ||
| const { userId, ...rest } = msg; | ||
| return rest as Message; | ||
| } | ||
| return msg; | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| current = next; | ||
| notifyBackend(next); | ||
| }, | ||
| }; | ||
|
|
||
| return manager; | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,76 @@ | ||
| import { getOrCreateSession, getOrCreateSessionId, getSessionId } from './session'; | ||
|
|
||
| const SESSION_COOKIE = '_imtbl_sid'; | ||
|
|
||
| // Mock internal modules | ||
| const mockGetCookie = jest.fn(); | ||
| const mockSetCookie = jest.fn(); | ||
| const mockGenerateId = jest.fn(); | ||
|
|
||
| jest.mock('./cookie', () => ({ | ||
| getCookie: (...args: unknown[]) => mockGetCookie(...args), | ||
| setCookie: (...args: unknown[]) => mockSetCookie(...args), | ||
| })); | ||
|
|
||
| jest.mock('./utils', () => ({ | ||
| generateId: (...args: unknown[]) => mockGenerateId(...args), | ||
| })); | ||
|
|
||
| beforeEach(() => { | ||
| jest.clearAllMocks(); | ||
| mockGenerateId.mockReturnValue('new-session-id'); | ||
| }); | ||
|
|
||
| describe('getOrCreateSession', () => { | ||
| it('creates a new session when no cookie exists', () => { | ||
| mockGetCookie.mockReturnValue(undefined); | ||
|
|
||
| const result = getOrCreateSession(); | ||
| expect(result.sessionId).toBe('new-session-id'); | ||
| expect(result.isNew).toBe(true); | ||
| expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, undefined); | ||
| }); | ||
|
|
||
| it('returns existing session and refreshes expiry', () => { | ||
| mockGetCookie.mockReturnValue('existing-sid'); | ||
|
|
||
| const result = getOrCreateSession(); | ||
| expect(result.sessionId).toBe('existing-sid'); | ||
| expect(result.isNew).toBe(false); | ||
| expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'existing-sid', 1800, undefined); | ||
| expect(mockGenerateId).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('passes domain to setCookie', () => { | ||
| mockGetCookie.mockReturnValue(undefined); | ||
|
|
||
| getOrCreateSession('.example.com'); | ||
| expect(mockSetCookie).toHaveBeenCalledWith(SESSION_COOKIE, 'new-session-id', 1800, '.example.com'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getOrCreateSessionId', () => { | ||
| it('returns the session ID string', () => { | ||
| mockGetCookie.mockReturnValue(undefined); | ||
|
|
||
| const id = getOrCreateSessionId(); | ||
| expect(id).toBe('new-session-id'); | ||
| }); | ||
|
|
||
| it('returns existing session ID', () => { | ||
| mockGetCookie.mockReturnValue('existing-sid'); | ||
| expect(getOrCreateSessionId()).toBe('existing-sid'); | ||
| }); | ||
| }); | ||
|
|
||
| describe('getSessionId', () => { | ||
| it('returns the session ID from cookie', () => { | ||
| mockGetCookie.mockReturnValue('existing-sid'); | ||
| expect(getSessionId()).toBe('existing-sid'); | ||
| }); | ||
|
|
||
| it('returns undefined when no session cookie exists', () => { | ||
| mockGetCookie.mockReturnValue(undefined); | ||
| expect(getSessionId()).toBeUndefined(); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import { getCookie, setCookie } from './cookie'; | ||
| import { generateId } from './utils'; | ||
| import { SESSION_COOKIE, SESSION_MAX_AGE } from './config'; | ||
|
|
||
| export interface SessionResult { | ||
| sessionId: string; | ||
| isNew: boolean; | ||
| } | ||
|
|
||
| /** | ||
| * Get or create a session ID. | ||
| * | ||
| * The session cookie has a 30-minute rolling expiry — each call refreshes it. | ||
| * Returns whether the session is new so the caller can fire a `session_start` event. | ||
| */ | ||
| export function getOrCreateSession(domain?: string): SessionResult { | ||
| const existing = getCookie(SESSION_COOKIE); | ||
| if (existing) { | ||
| // Refresh the rolling expiry | ||
| setCookie(SESSION_COOKIE, existing, SESSION_MAX_AGE, domain); | ||
| return { sessionId: existing, isNew: false }; | ||
| } | ||
|
|
||
| const id = generateId(); | ||
| setCookie(SESSION_COOKIE, id, SESSION_MAX_AGE, domain); | ||
| return { sessionId: id, isNew: true }; | ||
| } | ||
|
|
||
| /** Convenience wrapper that returns just the session ID string. */ | ||
| export function getOrCreateSessionId(domain?: string): string { | ||
| return getOrCreateSession(domain).sessionId; | ||
| } | ||
|
|
||
| export function getSessionId(): string | undefined { | ||
| return getCookie(SESSION_COOKIE); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| /** Injected at build time by tsup `define` — see tsup.config.ts */ | ||
| declare const PIXEL_VERSION_INJECTED: string; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.