Skip to content

Commit 470f455

Browse files
fix(audience): address review findings in SDK
- Remove unused consentSource config field - Remove dead transpile:cdn and demo scripts from package.json - Add session_start/session_end lifecycle events matching Pixel behavior - Add TODO for cookie cleanup to move to core's createConsentManager Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6cabb5f commit 470f455

4 files changed

Lines changed: 127 additions & 7 deletions

File tree

packages/audience/sdk/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,10 @@
4848
"scripts": {
4949
"build": "pnpm transpile && pnpm typegen",
5050
"transpile": "tsup src/index.ts --config ../../../tsup.config.js",
51-
"transpile:cdn": "tsup --config tsup.cdn.js",
5251
"typegen": "tsc --customConditions default --emitDeclarationOnly --outDir dist/types",
5352
"lint": "eslint ./src --ext .ts,.jsx,.tsx --max-warnings=0",
5453
"test": "jest --passWithNoTests",
5554
"test:watch": "jest --watch",
56-
"demo": "pnpm build && npx serve -l 3456 --cors ..",
5755
"typecheck": "tsc --customConditions development --noEmit --jsx preserve"
5856
},
5957
"type": "module",

packages/audience/sdk/src/sdk.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import {
33
MessageQueue,
44
createConsentManager,
5+
getOrCreateSession,
56
deleteCookie,
67
} from '@imtbl/audience-core';
78
import { ImmutableAudienceSDK } from './sdk';
@@ -41,6 +42,7 @@ jest.mock('@imtbl/audience-core', () => {
4142
collectAttribution: jest.fn(() => ({})),
4243
deleteCookie: jest.fn(),
4344
httpTransport: { send: jest.fn() },
45+
isBrowser: jest.fn(() => true),
4446
generateId: actual.generateId,
4547
getTimestamp: actual.getTimestamp,
4648
getBaseUrl: actual.getBaseUrl,
@@ -171,4 +173,37 @@ describe('ImmutableAudienceSDK', () => {
171173
sdk.track('late-event');
172174
expect(queue.enqueue).not.toHaveBeenCalled();
173175
});
176+
177+
describe('session lifecycle', () => {
178+
it('fires session_start on new session', () => {
179+
(getOrCreateSession as jest.Mock).mockReturnValue({ sessionId: 'new-sess', isNew: true });
180+
181+
const sdk = createSDK({ consent: 'anonymous' });
182+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
183+
consent.setLevel('anonymous');
184+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
185+
186+
sdk.page();
187+
188+
const enqueued = queue.enqueue.mock.calls.map((c: any[]) => c[0]);
189+
const sessionStart = enqueued.find((m: any) => m.eventName === 'session_start');
190+
expect(sessionStart).toBeDefined();
191+
expect(sessionStart.properties.sessionId).toBe('new-sess');
192+
});
193+
194+
it('does not fire session_start on existing session', () => {
195+
(getOrCreateSession as jest.Mock).mockReturnValue({ sessionId: 'old-sess', isNew: false });
196+
197+
const sdk = createSDK({ consent: 'anonymous' });
198+
const consent = (createConsentManager as jest.Mock).mock.results[0].value;
199+
consent.setLevel('anonymous');
200+
const queue = (MessageQueue as jest.Mock).mock.results[0].value;
201+
202+
sdk.page();
203+
204+
const enqueued = queue.enqueue.mock.calls.map((c: any[]) => c[0]);
205+
const sessionStart = enqueued.find((m: any) => m.eventName === 'session_start');
206+
expect(sessionStart).toBeUndefined();
207+
});
208+
});
174209
});

packages/audience/sdk/src/sdk.ts

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
deleteCookie,
2222
generateId,
2323
getTimestamp,
24+
isBrowser,
2425
createConsentManager,
2526
collectAttribution,
2627
} from '@imtbl/audience-core';
@@ -37,12 +38,18 @@ export class ImmutableAudienceSDK {
3738

3839
private userId: string | undefined;
3940

41+
private sessionId: string | undefined;
42+
43+
private sessionStartTime: number | undefined;
44+
4045
private debug: DebugLogger;
4146

4247
private config: Required<Pick<AudienceSDKConfig, 'publishableKey' | 'environment'>> & AudienceSDKConfig;
4348

4449
private destroyed = false;
4550

51+
private unloadHandler?: () => void;
52+
4653
constructor(config: AudienceSDKConfig) {
4754
const {
4855
publishableKey,
@@ -82,14 +89,16 @@ export class ImmutableAudienceSDK {
8289
);
8390

8491
this.queue.start();
92+
this.registerSessionEnd();
8593
}
8694

8795
// -- Public API ---------------------------------------------------------
8896

8997
page(properties?: Record<string, unknown>): void {
9098
if (!this.canTrack()) return;
9199

92-
const { sessionId } = this.touchSession();
100+
const { sessionId, isNew } = this.touchSession();
101+
this.refreshSession(sessionId, isNew);
93102
const attribution = collectAttribution();
94103
const context = collectContext();
95104

@@ -111,7 +120,8 @@ export class ImmutableAudienceSDK {
111120
track(eventName: string, properties?: Record<string, unknown>): void {
112121
if (!this.canTrack()) return;
113122

114-
const { sessionId } = this.touchSession();
123+
const { sessionId, isNew } = this.touchSession();
124+
this.refreshSession(sessionId, isNew);
115125
const context = collectContext();
116126

117127
const message: TrackMessage = {
@@ -134,7 +144,8 @@ export class ImmutableAudienceSDK {
134144
if (this.destroyed || this.consent.level !== 'full') return;
135145

136146
this.userId = userId;
137-
const { sessionId } = this.touchSession();
147+
const { sessionId, isNew } = this.touchSession();
148+
this.refreshSession(sessionId, isNew);
138149
const context = collectContext();
139150

140151
const message: IdentifyMessage = {
@@ -179,7 +190,8 @@ export class ImmutableAudienceSDK {
179190
const previous = this.consent.level;
180191
this.consent.setLevel(level);
181192

182-
// Clear cookies on revocation (core handles queue purge)
193+
// TODO: cookie cleanup should move to core's createConsentManager
194+
// so all surfaces (SDK, Pixel) get consistent revocation behavior.
183195
if (level === 'none') {
184196
deleteCookie(COOKIE_NAME, this.config.cookieDomain);
185197
deleteCookie(SESSION_COOKIE, this.config.cookieDomain);
@@ -194,9 +206,85 @@ export class ImmutableAudienceSDK {
194206

195207
destroy(): void {
196208
this.destroyed = true;
209+
this.removeSessionEnd();
197210
this.queue.destroy();
198211
}
199212

213+
// -- Session lifecycle --------------------------------------------------
214+
215+
private refreshSession(sessionId: string, isNew: boolean): void {
216+
this.sessionId = sessionId;
217+
if (isNew) {
218+
this.sessionStartTime = Date.now();
219+
this.fireSessionStart(sessionId);
220+
}
221+
}
222+
223+
private fireSessionStart(sessionId: string): void {
224+
if (!this.canTrack()) return;
225+
226+
const message: TrackMessage = {
227+
type: 'track',
228+
eventName: 'session_start',
229+
messageId: generateId(),
230+
eventTimestamp: getTimestamp(),
231+
anonymousId: this.anonymousId,
232+
surface: 'web',
233+
context: collectContext(),
234+
properties: { sessionId },
235+
userId: this.consent.level === 'full' ? this.userId : undefined,
236+
};
237+
238+
this.debug.logEvent('session_start', message);
239+
this.queue.enqueue(message);
240+
}
241+
242+
private fireSessionEnd(): void {
243+
if (!this.canTrack() || !this.sessionId) return;
244+
245+
const duration = this.sessionStartTime
246+
? Math.round((Date.now() - this.sessionStartTime) / 1000)
247+
: undefined;
248+
249+
const message: TrackMessage = {
250+
type: 'track',
251+
eventName: 'session_end',
252+
messageId: generateId(),
253+
eventTimestamp: getTimestamp(),
254+
anonymousId: this.anonymousId,
255+
surface: 'web',
256+
context: collectContext(),
257+
properties: {
258+
sessionId: this.sessionId,
259+
duration,
260+
},
261+
userId: this.consent.level === 'full' ? this.userId : undefined,
262+
};
263+
264+
this.debug.logEvent('session_end', message);
265+
this.queue.enqueue(message);
266+
}
267+
268+
private registerSessionEnd(): void {
269+
if (!isBrowser()) return;
270+
271+
this.unloadHandler = () => this.fireSessionEnd();
272+
273+
document.addEventListener('visibilitychange', () => {
274+
if (document.visibilityState === 'hidden') {
275+
this.unloadHandler?.();
276+
}
277+
});
278+
window.addEventListener('pagehide', this.unloadHandler);
279+
}
280+
281+
private removeSessionEnd(): void {
282+
if (this.unloadHandler) {
283+
window.removeEventListener('pagehide', this.unloadHandler);
284+
this.unloadHandler = undefined;
285+
}
286+
}
287+
200288
// -- Internals ----------------------------------------------------------
201289

202290
private canTrack(): boolean {

packages/audience/sdk/src/types.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ export interface AudienceSDKConfig {
66
environment: Environment;
77
/** Defaults to 'none' — no tracking until explicitly opted in. */
88
consent?: ConsentLevel;
9-
consentSource?: string;
109
debug?: boolean;
1110
cookieDomain?: string;
1211
flushInterval?: number;

0 commit comments

Comments
 (0)