From f6acf991c7c2bc7a5ce3231b90aa3accdf3e9506 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 8 Apr 2026 12:31:36 +1000 Subject: [PATCH 1/5] feat(audience): add debug logger and AudienceConfig - Rename package to @imtbl/audience - DebugLogger: opt-in console logging for events, flushes, consent changes, and warnings - AudienceConfig: publishable key, environment, consent, debug, cookie domain, flush interval/size - Config constants: library name/version, log prefix, consent source, session event names Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/package.json | 4 ++-- packages/audience/sdk/src/config.ts | 18 ++++++++++++++++-- packages/audience/sdk/src/context.test.ts | 11 ----------- packages/audience/sdk/src/context.ts | 7 ------- packages/audience/sdk/src/debug.test.ts | 13 +++++++------ packages/audience/sdk/src/debug.ts | 11 +++++------ packages/audience/sdk/src/types.ts | 12 +++++++++--- 7 files changed, 39 insertions(+), 37 deletions(-) delete mode 100644 packages/audience/sdk/src/context.test.ts delete mode 100644 packages/audience/sdk/src/context.ts diff --git a/packages/audience/sdk/package.json b/packages/audience/sdk/package.json index 6365fce448..28c37fda44 100644 --- a/packages/audience/sdk/package.json +++ b/packages/audience/sdk/package.json @@ -1,6 +1,6 @@ { - "name": "@imtbl/audience-sdk", - "description": "Immutable Audience SDK — consent-aware event tracking and identity management", + "name": "@imtbl/audience", + "description": "Immutable Audience — consent-aware event tracking and identity management", "version": "0.0.0", "author": "Immutable", "bugs": "https://github.com/immutable/ts-immutable-sdk/issues", diff --git a/packages/audience/sdk/src/config.ts b/packages/audience/sdk/src/config.ts index a889a5c232..f5830e766e 100644 --- a/packages/audience/sdk/src/config.ts +++ b/packages/audience/sdk/src/config.ts @@ -1,6 +1,20 @@ // SDK-specific constants. // Backend endpoints and base URLs come from @imtbl/audience-core. -export const LIBRARY_NAME = '@imtbl/audience-sdk'; -// Replaced at build time by esbuild replace plugin +export const LIBRARY_NAME = '@imtbl/audience'; +/** Replaced at build time by esbuild replace plugin. */ export const LIBRARY_VERSION = '__SDK_VERSION__'; + +/** Log prefix for console messages from this package. */ +export const LOG_PREFIX = '[audience]'; + +/** Default consent source when consentSource is not provided in config. */ +export const DEFAULT_CONSENT_SOURCE = 'WebSDK'; + +// --- Auto-tracked event names --- +// These are fired by the SDK lifecycle, not by studio code. + +/** Fired on init (or consent upgrade from none) when no active session cookie exists. */ +export const SESSION_START = 'session_start'; +/** Fired on explicit shutdown(). Not fired on tab close or consent revocation. */ +export const SESSION_END = 'session_end'; diff --git a/packages/audience/sdk/src/context.test.ts b/packages/audience/sdk/src/context.test.ts deleted file mode 100644 index 2444425e0e..0000000000 --- a/packages/audience/sdk/src/context.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { collectContext } from './context'; -import { LIBRARY_NAME, LIBRARY_VERSION } from './config'; - -describe('collectContext', () => { - it('should return context with SDK library name and version', () => { - const ctx = collectContext(); - - expect(ctx.library).toBe(LIBRARY_NAME); - expect(ctx.libraryVersion).toBe(LIBRARY_VERSION); - }); -}); diff --git a/packages/audience/sdk/src/context.ts b/packages/audience/sdk/src/context.ts deleted file mode 100644 index 7fec3e8ef6..0000000000 --- a/packages/audience/sdk/src/context.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { EventContext } from '@imtbl/audience-core'; -import { collectContext as coreCollectContext } from '@imtbl/audience-core'; -import { LIBRARY_NAME, LIBRARY_VERSION } from './config'; - -export function collectContext(): EventContext { - return coreCollectContext(LIBRARY_NAME, LIBRARY_VERSION); -} diff --git a/packages/audience/sdk/src/debug.test.ts b/packages/audience/sdk/src/debug.test.ts index afecf4bb04..795f6c40cf 100644 --- a/packages/audience/sdk/src/debug.test.ts +++ b/packages/audience/sdk/src/debug.test.ts @@ -1,5 +1,6 @@ import type { Message } from '@imtbl/audience-core'; import { DebugLogger } from './debug'; +import { LOG_PREFIX } from './config'; describe('DebugLogger', () => { let logSpy: jest.SpyInstance; @@ -22,7 +23,7 @@ describe('DebugLogger', () => { anonymousId: 'anon-1', surface: 'web', context: { library: 'test', libraryVersion: '0.0.0' }, - event: 'click', + eventName: 'click', properties: {}, }; @@ -42,7 +43,7 @@ describe('DebugLogger', () => { logger.logEvent('track', stubMessage); expect(logSpy).toHaveBeenCalledWith( - '[Immutable Audience] track', + `${LOG_PREFIX} track`, stubMessage, ); }); @@ -52,12 +53,12 @@ describe('DebugLogger', () => { logger.logFlush(true, 5); expect(logSpy).toHaveBeenCalledWith( - '[Immutable Audience] flush ok (5 messages)', + `${LOG_PREFIX} flush ok (5 messages)`, ); logger.logFlush(false, 3); expect(logSpy).toHaveBeenCalledWith( - '[Immutable Audience] flush failed (3 messages)', + `${LOG_PREFIX} flush failed (3 messages)`, ); }); @@ -66,7 +67,7 @@ describe('DebugLogger', () => { logger.logConsent('none', 'full'); expect(logSpy).toHaveBeenCalledWith( - '[Immutable Audience] consent none → full', + `${LOG_PREFIX} consent none \u2192 full`, ); }); @@ -75,7 +76,7 @@ describe('DebugLogger', () => { logger.logWarning('something went wrong'); expect(warnSpy).toHaveBeenCalledWith( - '[Immutable Audience] something went wrong', + `${LOG_PREFIX} something went wrong`, ); }); }); diff --git a/packages/audience/sdk/src/debug.ts b/packages/audience/sdk/src/debug.ts index 7d67bb335e..8c16488bc3 100644 --- a/packages/audience/sdk/src/debug.ts +++ b/packages/audience/sdk/src/debug.ts @@ -1,6 +1,5 @@ import type { ConsentLevel, Message } from '@imtbl/audience-core'; - -const PREFIX = '[Immutable Audience]'; +import { LOG_PREFIX } from './config'; export class DebugLogger { private enabled: boolean; @@ -12,24 +11,24 @@ export class DebugLogger { logEvent(method: string, message: Message): void { if (!this.enabled) return; // eslint-disable-next-line no-console - console.log(`${PREFIX} ${method}`, message); + console.log(`${LOG_PREFIX} ${method}`, message); } logFlush(ok: boolean, count: number): void { if (!this.enabled) return; // eslint-disable-next-line no-console - console.log(`${PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`); + console.log(`${LOG_PREFIX} flush ${ok ? 'ok' : 'failed'} (${count} messages)`); } logConsent(from: ConsentLevel, to: ConsentLevel): void { if (!this.enabled) return; // eslint-disable-next-line no-console - console.log(`${PREFIX} consent ${from} → ${to}`); + console.log(`${LOG_PREFIX} consent ${from} → ${to}`); } logWarning(msg: string): void { if (!this.enabled) return; // eslint-disable-next-line no-console - console.warn(`${PREFIX} ${msg}`); + console.warn(`${LOG_PREFIX} ${msg}`); } } diff --git a/packages/audience/sdk/src/types.ts b/packages/audience/sdk/src/types.ts index 2126c90c57..6d02bcc3c4 100644 --- a/packages/audience/sdk/src/types.ts +++ b/packages/audience/sdk/src/types.ts @@ -1,13 +1,19 @@ import type { Environment, ConsentLevel } from '@imtbl/audience-core'; -/** Configuration for the Immutable Audience SDK. */ -export interface AudienceSDKConfig { +/** Configuration for the Immutable Web SDK. */ +export interface AudienceConfig { + /** Publishable API key from Immutable Hub (pk_imtbl_...). */ publishableKey: string; + /** Target environment — controls which backend receives events. */ environment: Environment; - /** Defaults to 'none' — no tracking until explicitly opted in. */ + /** Initial consent level. Defaults to 'none' (no tracking until opted in). */ consent?: ConsentLevel; + /** Enable console logging of all events, flushes, and consent changes. */ debug?: boolean; + /** Cookie domain for cross-subdomain sharing (e.g. '.studio.com'). */ cookieDomain?: string; + /** Queue flush interval in milliseconds. Defaults to 5000. */ flushInterval?: number; + /** Number of queued messages that triggers an automatic flush. Defaults to 20. */ flushSize?: number; } From fcf6063a013cfcb2eb2d221fe9afc6e99e504272 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 8 Apr 2026 12:31:47 +1000 Subject: [PATCH 2/5] feat(audience): add Audience class with consent, tracking, and identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audience.init() provides consent-aware event tracking for web: - track(), page(), identify(), alias() with three-tier consent gating - Uses core's createConsentManager (shared with pixel), collectAttribution, and getOrCreateSession — no duplicated logic - session_start/session_end lifecycle with duration tracking - Attribution capture (UTM, click IDs, referrer) on first page view - setConsent() with queue cleanup on downgrade, session restart on upgrade - reset() for player logout, flush() for manual send, shutdown() for cleanup 46 tests covering all public methods, consent transitions, and edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/index.ts | 6 +- packages/audience/sdk/src/sdk.test.ts | 739 ++++++++++++++++++++++++++ packages/audience/sdk/src/sdk.ts | 424 +++++++++++++++ pnpm-lock.yaml | 137 +---- 4 files changed, 1179 insertions(+), 127 deletions(-) create mode 100644 packages/audience/sdk/src/sdk.test.ts create mode 100644 packages/audience/sdk/src/sdk.ts diff --git a/packages/audience/sdk/src/index.ts b/packages/audience/sdk/src/index.ts index 23dad10615..b36562b789 100644 --- a/packages/audience/sdk/src/index.ts +++ b/packages/audience/sdk/src/index.ts @@ -1,3 +1,3 @@ -export type { AudienceSDKConfig } from './types'; -export { DebugLogger } from './debug'; -export { collectContext } from './context'; +export { Audience } from './sdk'; +export type { AudienceConfig } from './types'; +export type { Environment, ConsentLevel, UserTraits } from '@imtbl/audience-core'; diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts new file mode 100644 index 0000000000..310a9d4735 --- /dev/null +++ b/packages/audience/sdk/src/sdk.test.ts @@ -0,0 +1,739 @@ +import { + COOKIE_NAME, SESSION_COOKIE, INGEST_PATH, CONSENT_PATH, +} from '@imtbl/audience-core'; +import { Audience } from './sdk'; +import { LIBRARY_NAME, SESSION_START, SESSION_END } from './config'; + +// --- Test fixtures --- +const TEST_USER = { uid: 'user@example.com', provider: 'email' } as const; +const TEST_STEAM = { uid: '76561198012345', provider: 'steam' } as const; + +function createSDK(overrides: Record = {}) { + return Audience.init({ + publishableKey: 'pk_imtbl_test', + environment: 'sandbox', + consent: 'full', + ...overrides, + }); +} + +const originalLocation = window.location; +const fetchCalls: { url: string; init: RequestInit }[] = []; + +const mockFetch = jest.fn().mockImplementation( + async (url: string, init?: RequestInit) => { + fetchCalls.push({ url: url as string, init: init ?? {} }); + return { ok: true, json: async () => ({}) }; + }, +); +global.fetch = mockFetch; + +function sentMessages(): any[] { + return fetchCalls + .filter((c) => c.url.includes(INGEST_PATH)) + .flatMap((c) => JSON.parse(c.init.body as string).messages); +} + +beforeEach(() => { + jest.clearAllMocks(); + fetchCalls.length = 0; + jest.useFakeTimers(); + document.cookie.split(';').forEach((c) => { + document.cookie = `${c.trim().split('=')[0]}=;max-age=0;path=/`; + }); + localStorage.clear(); + sessionStorage.clear(); +}); + +afterEach(() => { + // Reset instance counter so each test starts fresh + (Audience as any).liveInstances = 0; + jest.useRealTimers(); + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); +}); + +describe('Audience', () => { + describe('init', () => { + it('creates an SDK instance via static init()', () => { + const sdk = createSDK(); + expect(sdk).toBeInstanceOf(Audience); + sdk.shutdown(); + }); + + it('creates anonymous ID cookie when consent allows', () => { + const sdk = createSDK({ consent: 'anonymous' }); + expect(document.cookie).toContain(`${COOKIE_NAME}=`); + sdk.shutdown(); + }); + + it('does not create identity cookies at none consent', () => { + const sdk = createSDK({ consent: 'none' }); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + sdk.shutdown(); + }); + + it('throws if publishableKey is empty', () => { + expect(() => Audience.init({ + publishableKey: '', + environment: 'sandbox', + })).toThrow('publishableKey is required'); + }); + + it('throws if publishableKey is whitespace only', () => { + expect(() => Audience.init({ + publishableKey: ' ', + environment: 'sandbox', + })).toThrow('publishableKey is required'); + }); + + it('warns on double init but still creates a new instance', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const first = createSDK(); + const second = Audience.init({ + publishableKey: 'pk_imtbl_other', + environment: 'production', + consent: 'none', + }); + + expect(second).not.toBe(first); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Multiple SDK instances detected'), + ); + + warnSpy.mockRestore(); + first.shutdown(); + second.shutdown(); + }); + + it('does not warn after previous instance is shut down', () => { + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(); + const first = createSDK(); + first.shutdown(); + warnSpy.mockClear(); + + const second = createSDK(); + expect(warnSpy).not.toHaveBeenCalledWith( + expect.stringContaining('Multiple SDK instances'), + ); + + warnSpy.mockRestore(); + second.shutdown(); + }); + + it('emits session_start on new session', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(msg).toBeDefined(); + expect(msg.properties).toHaveProperty('sessionId'); + + sdk.shutdown(); + }); + + it('includes attribution on session_start', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=youtube&utm_campaign=launch', + href: 'https://studio.com/?utm_source=youtube&utm_campaign=launch', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(msg).toBeDefined(); + expect(msg.properties).toHaveProperty('sessionId'); + expect(msg.properties).toHaveProperty('utm_source', 'youtube'); + expect(msg.properties).toHaveProperty('utm_campaign', 'launch'); + + sdk.shutdown(); + }); + }); + + describe('track', () => { + it('enqueues an event and flushes', async () => { + const sdk = createSDK(); + + sdk.track('purchase', { + currency: 'USD', + value: 9.99, + itemId: 'sword_01', + }); + + await sdk.flush(); + + const msgs = sentMessages(); + const msg = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + currency: 'USD', + value: 9.99, + itemId: 'sword_01', + }); + expect(msg.surface).toBe('web'); + expect(msg.context.library).toBe(LIBRARY_NAME); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + expect(sentMessages()).toHaveLength(0); + sdk.shutdown(); + }); + + it('excludes userId at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.track('sign_in', { method: 'passport' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'sign_in', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('includes userId at full consent after identify', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.track('level_up', { level: 5 }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'level_up', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBe(TEST_USER.uid); + + sdk.shutdown(); + }); + }); + + describe('page', () => { + it('enqueues a page message', async () => { + const sdk = createSDK(); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.properties).toMatchObject({ section: 'shop' }); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const pages = sentMessages().filter((m: any) => m.type === 'page'); + expect(pages).toHaveLength(0); + + sdk.shutdown(); + }); + + it('excludes userId at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('includes userId at full consent after identify', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.page({ section: 'shop' }); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.userId).toBe(TEST_USER.uid); + + sdk.shutdown(); + }); + + it('attaches attribution to the first page view', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=youtube', + href: 'https://studio.com/?utm_source=youtube', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK(); + sdk.page(); + sdk.page(); + await sdk.flush(); + + const pages = sentMessages().filter( + (m: any) => m.type === 'page', + ); + expect(pages[0].properties).toHaveProperty( + 'utm_source', + 'youtube', + ); + if (pages[1]) { + expect(pages[1].properties?.utm_source).toBeUndefined(); + } + + sdk.shutdown(); + }); + }); + + describe('identify', () => { + it('sends an identify message at full consent', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider, { + name: 'Player One', + }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'identify', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBe(TEST_USER.uid); + expect(msg.identityType).toBe(TEST_USER.provider); + expect(msg.traits).toEqual({ name: 'Player One' }); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + await sdk.flush(); + + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + expect(ids).toHaveLength(0); + + sdk.shutdown(); + }); + + it('is a no-op at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + await sdk.flush(); + + const ids = sentMessages().filter( + (m: any) => m.type === 'identify', + ); + expect(ids).toHaveLength(0); + sdk.shutdown(); + }); + + it('ignores null passed as traits', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(null as any); + await sdk.flush(); + + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + // null is not a valid traits object — should not enqueue + expect(ids).toHaveLength(0); + + sdk.shutdown(); + }); + + it('ignores array passed as traits', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(['not', 'traits'] as any); + await sdk.flush(); + + const ids = sentMessages().filter((m: any) => m.type === 'identify'); + expect(ids).toHaveLength(0); + + sdk.shutdown(); + }); + + it('sends anonymous identify with traits only', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify({ + source: 'steam', + steamId: TEST_STEAM.uid, + }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'identify', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + expect(msg.traits).toEqual({ + source: 'steam', + steamId: TEST_STEAM.uid, + }); + + sdk.shutdown(); + }); + }); + + describe('alias', () => { + it('sends alias with fromId/fromType/toId/toType', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'alias', + ); + expect(msg).toBeDefined(); + expect(msg.fromId).toBe(TEST_STEAM.uid); + expect(msg.fromType).toBe(TEST_STEAM.provider); + expect(msg.toId).toBe(TEST_USER.uid); + expect(msg.toType).toBe(TEST_USER.provider); + + sdk.shutdown(); + }); + + it('rejects alias when from and to are identical', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.alias( + { uid: 'same_id', provider: 'steam' }, + { uid: 'same_id', provider: 'steam' }, + ); + await sdk.flush(); + + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + sdk.shutdown(); + }); + + it('is a no-op at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + sdk.shutdown(); + }); + + it('is a no-op at anonymous consent', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const aliases = sentMessages().filter((m: any) => m.type === 'alias'); + expect(aliases).toHaveLength(0); + + sdk.shutdown(); + }); + }); + + describe('setConsent', () => { + it('is a no-op when setting the same level', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.setConsent('full'); + await sdk.flush(); + + // No consent sync PUT should fire for same-level call + const consentCalls = fetchCalls.filter( + (c) => c.url.includes(CONSENT_PATH), + ); + expect(consentCalls).toHaveLength(0); + + sdk.shutdown(); + }); + + it('creates cookies and enables tracking on upgrade from none to full', async () => { + const sdk = createSDK({ consent: 'none' }); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + + sdk.setConsent('full'); + expect(document.cookie).toContain(`${COOKIE_NAME}=`); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.track('purchase', { value: 9.99 }); + await sdk.flush(); + + const trackMsg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + expect(trackMsg).toBeDefined(); + expect(trackMsg.userId).toBe(TEST_USER.uid); + + sdk.shutdown(); + }); + + it('starts queue and emits session_start when upgrading from none', async () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + expect(sentMessages()).toHaveLength(0); + + sdk.setConsent('anonymous'); + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + const msgs = sentMessages(); + const sessionStart = msgs.find( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(sessionStart).toBeDefined(); + expect(sessionStart.properties).toHaveProperty('sessionId'); + + const signUp = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'sign_up', + ); + expect(signUp).toBeDefined(); + + sdk.shutdown(); + }); + + it('purges identify/alias, strips userId on downgrade', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + sdk.alias(TEST_STEAM, TEST_USER); + sdk.track('purchase', { currency: 'USD', value: 9.99 }); + + sdk.setConsent('anonymous'); + await sdk.flush(); + + const msgs = sentMessages(); + expect( + msgs.every((m: any) => m.type !== 'identify'), + ).toBe(true); + expect( + msgs.every((m: any) => m.type !== 'alias'), + ).toBe(true); + const trackMsg = msgs.find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + expect(trackMsg).toBeDefined(); + expect(trackMsg.userId).toBeUndefined(); + + sdk.shutdown(); + }); + + it('clears identity cookies on downgrade to none', () => { + const sdk = createSDK({ consent: 'anonymous' }); + expect(document.cookie).toContain(`${COOKIE_NAME}=`); + expect(document.cookie).toContain(`${SESSION_COOKIE}=`); + + sdk.setConsent('none'); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + expect(document.cookie).not.toContain(`${SESSION_COOKIE}=`); + + sdk.shutdown(); + }); + + it('stops queue and makes track no-op on anonymous to none downgrade', async () => { + const sdk = createSDK({ consent: 'anonymous' }); + + sdk.track('before', { step: 1 }); + await sdk.flush(); + const beforeMsgs = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === 'before', + ); + expect(beforeMsgs.length).toBeGreaterThan(0); + fetchCalls.length = 0; + + sdk.setConsent('none'); + sdk.track('after', { step: 2 }); + await sdk.flush(); + + const afterMsgs = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === 'after', + ); + expect(afterMsgs).toHaveLength(0); + + sdk.shutdown(); + }); + + it('re-attaches attribution after consent downgrade and re-upgrade', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=tiktok', + href: 'https://studio.com/?utm_source=tiktok', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK({ consent: 'anonymous' }); + sdk.page(); + await sdk.flush(); + fetchCalls.length = 0; + + // Downgrade then re-upgrade + sdk.setConsent('none'); + sdk.setConsent('anonymous'); + sdk.page(); + await sdk.flush(); + + const pages = sentMessages().filter((m: any) => m.type === 'page'); + expect(pages[0]?.properties).toHaveProperty('utm_source', 'tiktok'); + + sdk.shutdown(); + }); + }); + + describe('shutdown', () => { + it('emits session_end with duration', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + fetchCalls.length = 0; + + jest.advanceTimersByTime(5000); + sdk.shutdown(); + + // destroy() calls flushUnload() which fires a keepalive fetch synchronously. + // Yield to ensure all microtasks settle before reading fetchCalls. + await Promise.resolve(); + await Promise.resolve(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === SESSION_END, + ); + expect(msg).toBeDefined(); + expect(msg.properties).toHaveProperty('sessionId'); + expect(msg.properties.duration).toBe(5); + }); + + it('does not emit session_end at none consent', async () => { + const sdk = createSDK({ consent: 'none' }); + sdk.shutdown(); + + expect(sentMessages().filter( + (m: any) => m.eventName === SESSION_END, + )).toHaveLength(0); + }); + }); + + describe('reset', () => { + it('clears pending messages from the queue', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.track('before_reset', { step: 1 }); + sdk.reset(); + await sdk.flush(); + + const msgs = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === 'before_reset', + ); + expect(msgs).toHaveLength(0); + + sdk.shutdown(); + }); + + it('clears userId and generates new anonymousId', async () => { + const sdk = createSDK({ consent: 'full' }); + + sdk.track('sign_in', { method: 'passport' }); + await sdk.flush(); + const originalAnonId = sentMessages().find( + (m: any) => m.type === 'track', + )?.anonymousId; + fetchCalls.length = 0; + + sdk.identify(TEST_USER.uid, TEST_USER.provider); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.reset(); + sdk.track('sign_up', { method: 'google' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track', + ); + expect(msg).toBeDefined(); + expect(msg.userId).toBeUndefined(); + expect(msg.anonymousId).toBeDefined(); + expect(msg.anonymousId).not.toBe(originalAnonId); + + sdk.shutdown(); + }); + + it('emits session_start after reset', async () => { + const sdk = createSDK({ consent: 'full' }); + await sdk.flush(); + fetchCalls.length = 0; + + sdk.reset(); + await sdk.flush(); + + const sessionStarts = sentMessages().filter( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + expect(sessionStarts).toHaveLength(1); + + sdk.shutdown(); + }); + + it('works at none consent without creating cookies', () => { + const sdk = createSDK({ consent: 'none' }); + + sdk.reset(); + expect(document.cookie).not.toContain(`${COOKIE_NAME}=`); + expect(document.cookie).not.toContain(`${SESSION_COOKIE}=`); + + sdk.shutdown(); + }); + }); +}); diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts new file mode 100644 index 0000000000..737c90b8bf --- /dev/null +++ b/packages/audience/sdk/src/sdk.ts @@ -0,0 +1,424 @@ +import type { + Attribution, + ConsentLevel, + ConsentManager, + Message, + UserTraits, +} from '@imtbl/audience-core'; +import { + INGEST_PATH, + FLUSH_INTERVAL_MS, + FLUSH_SIZE, + COOKIE_NAME, + SESSION_COOKIE, + MessageQueue, + httpTransport, + getBaseUrl, + getOrCreateAnonymousId, + getCookie, + deleteCookie, + generateId, + getTimestamp, + isAliasValid, + isTimestampValid, + truncate, + collectContext, + collectAttribution, + getOrCreateSession, + createConsentManager, +} from '@imtbl/audience-core'; +import { DebugLogger } from './debug'; +import type { AudienceConfig } from './types'; +import { + LIBRARY_NAME, LIBRARY_VERSION, LOG_PREFIX, DEFAULT_CONSENT_SOURCE, SESSION_START, SESSION_END, +} from './config'; + +/** + * Track player activity on your website — page views, purchases, sign-ups — + * and tie it to player identity when they log in. + * + * Create via `Audience.init()`. Call `shutdown()` when done. + */ +export class Audience { + private static liveInstances = 0; + + private readonly queue: MessageQueue; + + private readonly consent: ConsentManager; + + private readonly attribution: Attribution; + + private readonly debug: DebugLogger; + + private readonly cookieDomain?: string; + + private anonymousId: string; + + private sessionId: string | undefined; + + private sessionStartTime: number | undefined; + + private userId: string | undefined; + + private isFirstPage = true; + + private constructor(config: AudienceConfig) { + const { + cookieDomain, + environment, + publishableKey, + } = config; + const consentLevel = config.consent ?? 'none'; + const consentSource = DEFAULT_CONSENT_SOURCE; + const flushInterval = config.flushInterval ?? FLUSH_INTERVAL_MS; + const flushSize = config.flushSize ?? FLUSH_SIZE; + + this.cookieDomain = cookieDomain; + this.debug = new DebugLogger(config.debug ?? false); + + let isNewSession = false; + if (consentLevel !== 'none') { + this.anonymousId = getOrCreateAnonymousId(cookieDomain); + isNewSession = this.startSession(); + } else { + this.anonymousId = getCookie(COOKIE_NAME) ?? generateId(); + } + + const endpointUrl = `${getBaseUrl(environment)}${INGEST_PATH}`; + this.queue = new MessageQueue( + httpTransport, + endpointUrl, + publishableKey, + flushInterval, + flushSize, + { + onFlush: (ok, count) => this.debug.logFlush(ok, count), + staleFilter: (m) => isTimestampValid(m.eventTimestamp), + storagePrefix: '__imtbl_web_', + }, + ); + + this.consent = createConsentManager( + this.queue, + publishableKey, + this.anonymousId, + environment, + consentSource, + consentLevel, + ); + + this.attribution = collectAttribution(); + + if (!this.isTrackingDisabled()) { + this.queue.start(); + if (isNewSession) this.trackSessionStart(); + } + } + + /** + * Create and start the SDK. Warns if another instance is already active — + * call `shutdown()` on the previous one first. + */ + static init(config: AudienceConfig): Audience { + if (!config.publishableKey?.trim()) { + throw new Error(`${LOG_PREFIX} publishableKey is required`); + } + if (Audience.liveInstances > 0) { + // eslint-disable-next-line no-console + console.warn( + `${LOG_PREFIX} Multiple SDK instances detected.` + + ' Ensure previous instances are shut down to avoid duplicate events.', + ); + } + Audience.liveInstances += 1; + return new Audience(config); + } + + // --- Helpers --- + + /** True when consent is 'none' — SDK should not enqueue anything. */ + private isTrackingDisabled(): boolean { + return this.consent.level === 'none'; + } + + /** Returns userId if consent is full, undefined otherwise. */ + private effectiveUserId(): string | undefined { + return this.consent.level === 'full' ? this.userId : undefined; + } + + /** Create or resume a session, returning whether it's new. */ + private startSession(): boolean { + const session = getOrCreateSession(this.cookieDomain); + this.sessionId = session.sessionId; + this.sessionStartTime = Date.now(); + return session.isNew; + } + + // --- Message factory --- + + /** Common fields shared by every message. */ + private baseMessage() { + return { + messageId: generateId(), + eventTimestamp: getTimestamp(), + anonymousId: this.anonymousId, + surface: 'web' as const, + context: collectContext(LIBRARY_NAME, LIBRARY_VERSION), + }; + } + + private enqueue(label: string, message: Message): void { + this.queue.enqueue(message); + this.debug.logEvent(label, message); + } + + // --- Session lifecycle --- + + private trackSessionStart(): void { + if (!this.sessionId) return; + this.enqueue('track(session_start)', { + ...this.baseMessage(), + type: 'track', + eventName: SESSION_START, + properties: { + sessionId: this.sessionId, + ...this.attribution, + }, + }); + } + + private trackSessionEnd(): void { + if (!this.sessionId) return; + this.enqueue('track(session_end)', { + ...this.baseMessage(), + type: 'track', + eventName: SESSION_END, + properties: { + sessionId: this.sessionId, + ...(this.sessionStartTime && { + duration: Math.round((Date.now() - this.sessionStartTime) / 1000), + }), + }, + }); + } + + // --- Page tracking --- + + /** + * Record a page view. Call this on every route change in your app. + * The first call automatically captures how the player arrived + * (UTM params, ad click IDs, referrer). No-op when consent is 'none'. + */ + page(properties?: Record): void { + if (this.isTrackingDisabled()) return; + getOrCreateSession(this.cookieDomain); + + const mergedProps: Record = { ...properties }; + if (this.isFirstPage) { + Object.assign(mergedProps, this.attribution); + this.isFirstPage = false; + } + + this.enqueue('page', { + ...this.baseMessage(), + type: 'page', + properties: Object.keys(mergedProps).length > 0 ? mergedProps : undefined, + userId: this.effectiveUserId(), + }); + } + + // --- Event tracking --- + + /** + * Record a player action like a purchase, sign-up, or game launch. + * Pass the event name and any properties you want to analyse later. + * No-op when consent is 'none'. + */ + track(event: string, properties?: Record): void { + if (this.isTrackingDisabled()) return; + getOrCreateSession(this.cookieDomain); + + this.enqueue('track', { + ...this.baseMessage(), + type: 'track', + eventName: truncate(event), + properties, + userId: this.effectiveUserId(), + }); + } + + // --- Identity --- + + /** + * Tell the SDK who this player is. Call when a player logs in or links + * an account. Before identify(), the SDK only knows an anonymous cookie ID. + * After, all future events are tied to this player. + * + * Named: `sdk.identify('user@example.com', 'email', { name: 'Jane' })` + * Traits only: `sdk.identify({ source: 'steam', steamId: '765...' })` + * + * Requires 'full' consent. + */ + identify(uid: string, provider: string, traits?: UserTraits): void; + + identify(traits: UserTraits): void; + + identify( + uidOrTraits: string | UserTraits, + provider?: string, + traits?: UserTraits, + ): void { + if (this.consent.level !== 'full') { + this.debug.logWarning('identify() requires full consent — call ignored.'); + return; + } + getOrCreateSession(this.cookieDomain); + + if (uidOrTraits !== null && typeof uidOrTraits === 'object' && !Array.isArray(uidOrTraits)) { + this.enqueue('identify', { + ...this.baseMessage(), + type: 'identify', + traits: uidOrTraits, + }); + return; + } + + if (typeof uidOrTraits !== 'string') return; + + const uid = truncate(uidOrTraits); + this.userId = uid; + this.enqueue('identify', { + ...this.baseMessage(), + type: 'identify', + userId: uid, + identityType: provider, + traits, + }); + } + + /** + * Connect two accounts that belong to the same player. Use when a player + * previously known by one identity (e.g. Steam ID) creates or links a + * different account (e.g. Passport email). This tells the backend they're + * the same person so analytics aren't split across two profiles. + * + * Requires 'full' consent. `from` and `to` must differ. + */ + alias( + from: { uid: string; provider: string }, + to: { uid: string; provider: string }, + ): void { + if (this.consent.level !== 'full') { + this.debug.logWarning('alias() requires full consent — call ignored.'); + return; + } + if (!isAliasValid(from.uid, from.provider, to.uid, to.provider)) { + this.debug.logWarning('alias() from and to are identical — call ignored.'); + return; + } + getOrCreateSession(this.cookieDomain); + + this.enqueue('alias', { + ...this.baseMessage(), + type: 'alias', + fromId: truncate(from.uid), + fromType: from.provider, + toId: truncate(to.uid), + toType: to.provider, + }); + } + + // --- Consent --- + + /** + * Update tracking consent, typically in response to a cookie banner. + * Call whenever your consent management platform reports a change. + * + * - 'none': all tracking stops, cookies are cleared. + * - 'anonymous': track activity without knowing who the player is. + * - 'full': track everything including player identity. + */ + setConsent(level: ConsentLevel): void { + const previous = this.consent.level; + if (level === previous) return; + + this.debug.logConsent(previous, level); + + const isUpgradeFromNone = previous === 'none' && level !== 'none'; + + // When upgrading from none, create the persisted anonymousId first + // so the consent sync sends the correct ID to the server. + if (isUpgradeFromNone) { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + } + + // Web-specific cleanup before core handles queue purge/transform and server sync. + // session_end is intentionally not emitted — no events should be sent after opt-out. + if (level === 'none') { + this.queue.stop(); + } + + // Core handles: queue purge on →none, userId strip on →anonymous, server sync. + this.consent.setLevel(level); + + // Web-specific cleanup after core's transition. + if (level === 'none') { + deleteCookie(COOKIE_NAME, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + } else if (level === 'anonymous' && previous === 'full') { + this.userId = undefined; + this.queue.purge( + (m) => m.type === 'identify' || m.type === 'alias', + ); + } + + if (isUpgradeFromNone) { + this.isFirstPage = true; + const isNewSession = this.startSession(); + this.queue.start(); + if (isNewSession) this.trackSessionStart(); + } + } + + // --- Lifecycle --- + + /** + * Call on player logout. Generates a fresh anonymous ID so the next + * player on this device isn't confused with the previous one. Queued + * events from the previous session are discarded. + */ + reset(): void { + this.userId = undefined; + this.queue.clear(); + deleteCookie(COOKIE_NAME, this.cookieDomain); + deleteCookie(SESSION_COOKIE, this.cookieDomain); + if (!this.isTrackingDisabled()) { + this.anonymousId = getOrCreateAnonymousId(this.cookieDomain); + const isNewSession = this.startSession(); + if (isNewSession) this.trackSessionStart(); + } else { + this.anonymousId = generateId(); + this.sessionId = undefined; + this.sessionStartTime = undefined; + } + this.isFirstPage = true; + } + + /** + * Send all queued events now instead of waiting for the next automatic + * flush. Useful before navigating away from a critical page. + */ + async flush(): Promise { + await this.queue.flush(); + } + + /** + * Stop the SDK and send any remaining events. Call when your app + * unmounts or the player leaves. + */ + shutdown(): void { + if (!this.isTrackingDisabled()) this.trackSessionEnd(); + this.queue.destroy(); + Audience.liveInstances = Math.max(0, Audience.liveInstances - 1); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf636b2d46..21af326087 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,7 +138,7 @@ importers: version: 0.25.21(@emotion/react@11.11.3(@types/react@18.3.12)(react@18.3.1))(@rive-app/react-canvas-lite@4.9.0(react@18.3.1))(embla-carousel-react@8.1.5(react@18.3.1))(framer-motion@11.18.2(@emotion/is-prop-valid@0.8.8)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@imtbl/sdk': specifier: latest - version: 2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10) + version: 2.14.3(bufferutil@4.0.8)(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10) next: specifier: 14.2.25 version: 14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -1106,7 +1106,7 @@ importers: version: 15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) next-auth: specifier: ^5.0.0-beta.30 - version: 5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) + version: 5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) react: specifier: ^18.2.0 version: 18.3.1 @@ -14665,7 +14665,6 @@ packages: engines: {node: '>=0.6.0', teleport: '>=0.2.0'} deprecated: |- You or someone you depend on is using Q, the JavaScript Promise library that gave JavaScript developers strong feelings about promises. They can almost certainly migrate to the native JavaScript promise now. Thank you literally everyone for joining me in this bet against the odds. Be excellent to each other. - (For a CapTP with native promises, see @endo/eventual-send and @endo/captp) qr-code-styling@1.6.0-rc.1: @@ -20364,12 +20363,12 @@ snapshots: - supports-color - utf-8-validate - '@imtbl/checkout-sdk@2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/checkout-sdk@2.14.3(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/blockchain-data': 2.14.3 '@imtbl/bridge-sdk': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@imtbl/config': 2.14.3 - '@imtbl/dex-sdk': 2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10) + '@imtbl/dex-sdk': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) '@imtbl/generated-clients': 2.14.3 '@imtbl/metrics': 2.14.3 '@imtbl/orderbook': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20435,12 +20434,12 @@ snapshots: - typescript - utf-8-validate - '@imtbl/dex-sdk@2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(utf-8-validate@5.0.10)': + '@imtbl/dex-sdk@2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10)': dependencies: '@imtbl/config': 2.14.3 '@uniswap/sdk-core': 3.2.3 - '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) - '@uniswap/v3-sdk': 3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) + '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) + '@uniswap/v3-sdk': 3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) ethers: 6.13.5(bufferutil@4.0.8)(utf-8-validate@5.0.10) transitivePeerDependencies: - bufferutil @@ -20519,13 +20518,13 @@ snapshots: - encoding - supports-color - '@imtbl/sdk@2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10)': + '@imtbl/sdk@2.14.3(bufferutil@4.0.8)(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1)(typescript@5.6.2)(utf-8-validate@5.0.10)': dependencies: '@imtbl/auth': 2.14.3 '@imtbl/auth-next-client': 2.14.3(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1) '@imtbl/auth-next-server': 2.14.3(next-auth@5.0.0-beta.30(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1))(next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)) '@imtbl/blockchain-data': 2.14.3 - '@imtbl/checkout-sdk': 2.14.3(bufferutil@4.0.8)(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))(typescript@5.6.2)(utf-8-validate@5.0.10) + '@imtbl/checkout-sdk': 2.14.3(bufferutil@4.0.8)(typescript@5.6.2)(utf-8-validate@5.0.10) '@imtbl/config': 2.14.3 '@imtbl/minting-backend': 2.14.3 '@imtbl/orderbook': 2.14.3(bufferutil@4.0.8)(utf-8-validate@5.0.10) @@ -20750,43 +20749,6 @@ snapshots: - ts-node - utf-8-validate - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)': - dependencies: - '@jest/console': 29.7.0 - '@jest/reporters': 29.7.0(node-notifier@8.0.2) - '@jest/test-result': 29.7.0 - '@jest/transform': 29.7.0 - '@jest/types': 29.6.3 - '@types/node': 20.14.13 - ansi-escapes: 4.3.2 - chalk: 4.1.2 - ci-info: 3.8.0 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.14.13)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2)) - jest-haste-map: 29.7.0 - jest-message-util: 29.7.0 - jest-regex-util: 29.6.3 - jest-resolve: 29.7.0 - jest-resolve-dependencies: 29.7.0 - jest-runner: 29.7.0 - jest-runtime: 29.7.0 - jest-snapshot: 29.7.0 - jest-util: 29.7.0 - jest-validate: 29.7.0 - jest-watcher: 29.7.0 - micromatch: 4.0.5 - pretty-format: 29.7.0 - slash: 3.0.0 - strip-ansi: 6.0.1 - optionalDependencies: - node-notifier: 8.0.2 - transitivePeerDependencies: - - babel-plugin-macros - - supports-color - - ts-node - '@jest/core@29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))': dependencies: '@jest/console': 29.7.0 @@ -25204,17 +25166,6 @@ snapshots: tiny-invariant: 1.3.1 toformat: 2.0.0 - '@uniswap/swap-router-contracts@1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': - dependencies: - '@openzeppelin/contracts': 3.4.2 - '@uniswap/v2-core': 1.0.1 - '@uniswap/v3-core': 1.0.0 - '@uniswap/v3-periphery': 1.4.4 - dotenv: 14.3.2 - hardhat-watcher: 2.5.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) - transitivePeerDependencies: - - hardhat - '@uniswap/swap-router-contracts@1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': dependencies: '@openzeppelin/contracts': 3.4.2 @@ -25246,19 +25197,6 @@ snapshots: '@uniswap/v3-core': 1.0.0 base64-sol: 1.0.1 - '@uniswap/v3-sdk@3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': - dependencies: - '@ethersproject/abi': 5.7.0 - '@ethersproject/solidity': 5.7.0 - '@uniswap/sdk-core': 4.0.6 - '@uniswap/swap-router-contracts': 1.3.1(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)) - '@uniswap/v3-periphery': 1.4.3 - '@uniswap/v3-staker': 1.0.0 - tiny-invariant: 1.3.1 - tiny-warning: 1.0.3 - transitivePeerDependencies: - - hardhat - '@uniswap/v3-sdk@3.10.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10))': dependencies: '@ethersproject/abi': 5.7.0 @@ -27400,21 +27338,6 @@ snapshots: - supports-color - ts-node - create-jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0): - dependencies: - '@jest/types': 29.6.3 - chalk: 4.1.2 - exit: 0.1.2 - graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)) - jest-util: 29.7.0 - prompts: 2.4.2 - transitivePeerDependencies: - - '@types/node' - - babel-plugin-macros - - supports-color - - ts-node - create-jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)): dependencies: '@jest/types': 29.6.3 @@ -30020,11 +29943,6 @@ snapshots: - debug - utf-8-validate - hardhat-watcher@2.5.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)): - dependencies: - chokidar: 3.6.0 - hardhat: 2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10) - hardhat-watcher@2.5.0(hardhat@2.22.6(bufferutil@4.0.8)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2))(typescript@5.6.2)(utf-8-validate@5.0.10)): dependencies: chokidar: 3.6.0 @@ -31027,11 +30945,11 @@ snapshots: jest-cli@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2)) '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0) + create-jest: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)) exit: 0.1.2 import-local: 3.1.0 jest-config: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@22.19.7)(typescript@5.6.2)) @@ -32095,7 +32013,7 @@ snapshots: jest@29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2): dependencies: - '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) + '@jest/core': 29.7.0(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.3(@swc/helpers@0.5.15))(@types/node@20.14.13)(typescript@5.6.2)) '@jest/types': 29.6.3 import-local: 3.1.0 jest-cli: 29.7.0(@types/node@22.19.7)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2) @@ -33251,12 +33169,6 @@ snapshots: react: 18.3.1 optional: true - next-auth@5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): - dependencies: - '@auth/core': 0.41.0 - next: 15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) - react: 18.3.1 - next-auth@5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106))(react@19.0.0-rc-66855b96-20241106): dependencies: '@auth/core': 0.41.0 @@ -33266,7 +33178,7 @@ snapshots: next-auth@5.0.0-beta.30(next@15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(react@18.3.1): dependencies: '@auth/core': 0.41.0 - next: 15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + next: 15.5.10(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react: 18.3.1 next@14.2.25(@babel/core@7.26.10)(@playwright/test@1.45.3)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): @@ -33341,29 +33253,6 @@ snapshots: - '@babel/core' - babel-plugin-macros - next@15.5.10(@babel/core@7.26.10)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): - dependencies: - '@next/env': 15.5.10 - '@swc/helpers': 0.5.15 - caniuse-lite: 1.0.30001760 - postcss: 8.4.31 - react: 18.3.1 - react-dom: 18.3.1(react@18.3.1) - styled-jsx: 5.1.6(@babel/core@7.26.10)(babel-plugin-macros@3.1.0)(react@18.3.1) - optionalDependencies: - '@next/swc-darwin-arm64': 15.5.7 - '@next/swc-darwin-x64': 15.5.7 - '@next/swc-linux-arm64-gnu': 15.5.7 - '@next/swc-linux-arm64-musl': 15.5.7 - '@next/swc-linux-x64-gnu': 15.5.7 - '@next/swc-linux-x64-musl': 15.5.7 - '@next/swc-win32-arm64-msvc': 15.5.7 - '@next/swc-win32-x64-msvc': 15.5.7 - sharp: 0.34.5 - transitivePeerDependencies: - - '@babel/core' - - babel-plugin-macros - nice-napi@1.0.2: dependencies: node-addon-api: 3.2.1 From fdce15ea6b596ef1c35a218da13a9b932e1d850d Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 8 Apr 2026 13:46:32 +1000 Subject: [PATCH 3/5] refactor(audience): move SESSION_START/SESSION_END constants to core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the SDK and pixel use these event names — they belong in @imtbl/audience-core so both surfaces import from one place. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/config.ts | 3 +++ packages/audience/core/src/index.ts | 2 ++ packages/audience/sdk/src/config.ts | 8 -------- packages/audience/sdk/src/sdk.test.ts | 4 ++-- packages/audience/sdk/src/sdk.ts | 4 +++- 5 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/audience/core/src/config.ts b/packages/audience/core/src/config.ts index f1f775f317..00a8f0cdef 100644 --- a/packages/audience/core/src/config.ts +++ b/packages/audience/core/src/config.ts @@ -17,4 +17,7 @@ export const SESSION_COOKIE = '_imtbl_sid'; export const COOKIE_MAX_AGE_SECONDS = 365 * 24 * 60 * 60 * 2; // 2 years export const SESSION_MAX_AGE = 30 * 60; // 30 minutes in seconds +export const SESSION_START = 'session_start'; +export const SESSION_END = 'session_end'; + export const getBaseUrl = (environment: Environment): string => BASE_URLS[environment]; diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index d5e77b568e..7d3526aed0 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -34,6 +34,8 @@ export { COOKIE_NAME, SESSION_COOKIE, SESSION_MAX_AGE, + SESSION_START, + SESSION_END, } from './config'; export { generateId, getTimestamp, isBrowser } from './utils'; diff --git a/packages/audience/sdk/src/config.ts b/packages/audience/sdk/src/config.ts index f5830e766e..71775d60c9 100644 --- a/packages/audience/sdk/src/config.ts +++ b/packages/audience/sdk/src/config.ts @@ -10,11 +10,3 @@ export const LOG_PREFIX = '[audience]'; /** Default consent source when consentSource is not provided in config. */ export const DEFAULT_CONSENT_SOURCE = 'WebSDK'; - -// --- Auto-tracked event names --- -// These are fired by the SDK lifecycle, not by studio code. - -/** Fired on init (or consent upgrade from none) when no active session cookie exists. */ -export const SESSION_START = 'session_start'; -/** Fired on explicit shutdown(). Not fired on tab close or consent revocation. */ -export const SESSION_END = 'session_end'; diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index 310a9d4735..c290c7e773 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -1,8 +1,8 @@ import { - COOKIE_NAME, SESSION_COOKIE, INGEST_PATH, CONSENT_PATH, + COOKIE_NAME, SESSION_COOKIE, INGEST_PATH, CONSENT_PATH, SESSION_START, SESSION_END, } from '@imtbl/audience-core'; import { Audience } from './sdk'; -import { LIBRARY_NAME, SESSION_START, SESSION_END } from './config'; +import { LIBRARY_NAME } from './config'; // --- Test fixtures --- const TEST_USER = { uid: 'user@example.com', provider: 'email' } as const; diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index 737c90b8bf..a29e6f29ae 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -26,11 +26,13 @@ import { collectAttribution, getOrCreateSession, createConsentManager, + SESSION_START, + SESSION_END, } from '@imtbl/audience-core'; import { DebugLogger } from './debug'; import type { AudienceConfig } from './types'; import { - LIBRARY_NAME, LIBRARY_VERSION, LOG_PREFIX, DEFAULT_CONSENT_SOURCE, SESSION_START, SESSION_END, + LIBRARY_NAME, LIBRARY_VERSION, LOG_PREFIX, DEFAULT_CONSENT_SOURCE, } from './config'; /** From f9761df020ec1571af5aec5090f0ac7d57c84908 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 8 Apr 2026 13:49:20 +1000 Subject: [PATCH 4/5] fix(audience): guard shutdown() against double-call Add destroyed flag so calling shutdown() twice (e.g. React strict mode useEffect cleanup) doesn't queue duplicate session_end or decrement liveInstances below the real count. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/sdk/src/sdk.test.ts | 18 ++++++++++++++++++ packages/audience/sdk/src/sdk.ts | 4 ++++ 2 files changed, 22 insertions(+) diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index c290c7e773..7e67a045e5 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -655,6 +655,24 @@ describe('Audience', () => { expect(msg.properties.duration).toBe(5); }); + it('is safe to call twice (React strict mode)', async () => { + const sdk = createSDK({ consent: 'full' }); + sdk.shutdown(); + + await Promise.resolve(); + await Promise.resolve(); + fetchCalls.length = 0; + + sdk.shutdown(); + await Promise.resolve(); + await Promise.resolve(); + + const sessionEnds = sentMessages().filter( + (m: any) => m.eventName === SESSION_END, + ); + expect(sessionEnds).toHaveLength(0); + }); + it('does not emit session_end at none consent', async () => { const sdk = createSDK({ consent: 'none' }); sdk.shutdown(); diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index a29e6f29ae..8e089b2cf2 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -64,6 +64,8 @@ export class Audience { private isFirstPage = true; + private destroyed = false; + private constructor(config: AudienceConfig) { const { cookieDomain, @@ -419,6 +421,8 @@ export class Audience { * unmounts or the player leaves. */ shutdown(): void { + if (this.destroyed) return; + this.destroyed = true; if (!this.isTrackingDisabled()) this.trackSessionEnd(); this.queue.destroy(); Audience.liveInstances = Math.max(0, Audience.liveInstances - 1); From e98f452b0627eccd09ea628dc39c47eb55afc201 Mon Sep 17 00:00:00 2001 From: ImmutableJeffrey Date: Wed, 8 Apr 2026 13:49:58 +1000 Subject: [PATCH 5/5] fix(audience): purge identify/alias messages in core on anonymous downgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the identify/alias queue purge from the SDK into core's createConsentManager so both the SDK and pixel correctly remove PII-linked messages on full→anonymous downgrade. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/audience/core/src/consent.ts | 3 ++- packages/audience/sdk/src/sdk.ts | 6 ++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/audience/core/src/consent.ts b/packages/audience/core/src/consent.ts index 8d4d30ee26..02e14095d9 100644 --- a/packages/audience/core/src/consent.ts +++ b/packages/audience/core/src/consent.ts @@ -62,7 +62,8 @@ export function createConsentManager( // Purge all queued messages queue.purge(() => true); } else if (next === 'anonymous') { - // Strip userId from queued messages + // Remove identify/alias messages and strip userId from the rest + queue.purge((msg: Message) => msg.type === 'identify' || msg.type === 'alias'); queue.transform((msg: Message) => { if ('userId' in msg) { const { userId, ...rest } = msg; diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index 8e089b2cf2..3343c348e7 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -362,7 +362,8 @@ export class Audience { this.queue.stop(); } - // Core handles: queue purge on →none, userId strip on →anonymous, server sync. + // Core handles: queue purge on →none, identify/alias purge + userId strip + // on →anonymous, server sync. this.consent.setLevel(level); // Web-specific cleanup after core's transition. @@ -371,9 +372,6 @@ export class Audience { deleteCookie(SESSION_COOKIE, this.cookieDomain); } else if (level === 'anonymous' && previous === 'full') { this.userId = undefined; - this.queue.purge( - (m) => m.type === 'identify' || m.type === 'alias', - ); } if (isUpgradeFromNone) {