From 91eb9a721f28b2775b8725c0a7b102a0d17cd17b Mon Sep 17 00:00:00 2001 From: Natalie Bunduwongse Date: Tue, 7 Jul 2026 14:45:31 +1200 Subject: [PATCH] feat(audience): stamp sessionId on every message envelope Session id was previously injected ad hoc into the properties object at each call site, so identify() and alias() on the web SDK carried no session correlation since those message types have no properties field. Adding sessionId to the shared BaseMessage envelope closes that gap while keeping the existing properties.session_id fields for backward compatibility. --- packages/audience/core/src/types.ts | 1 + packages/audience/pixel/src/pixel.test.ts | 28 ++++-- packages/audience/pixel/src/pixel.ts | 14 ++- .../audience/sdk-sample-app/sample-app.js | 2 +- packages/audience/sdk/src/sdk.test.ts | 90 +++++++++++++++---- packages/audience/sdk/src/sdk.ts | 31 ++++--- 6 files changed, 118 insertions(+), 48 deletions(-) diff --git a/packages/audience/core/src/types.ts b/packages/audience/core/src/types.ts index 737ce01b41..aa3ea1cb81 100644 --- a/packages/audience/core/src/types.ts +++ b/packages/audience/core/src/types.ts @@ -29,6 +29,7 @@ interface BaseMessage { surface: Surface; context: EventContext; consentLevel?: ConsentLevel; + sessionId?: string; /** Present when the SDK/pixel is initialised with testMode: true. */ test?: true; } diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index f8863dc875..ff92cd5d70 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -115,7 +115,7 @@ describe('Pixel', () => { expect((pageCall!.properties as Record).utm_source).toBe('google'); expect(sessionStartCall).toBeDefined(); - expect((sessionStartCall!.properties as Record).session_id).toBe('session-abc'); + expect(sessionStartCall!.sessionId).toBe('session-abc'); }); it('does not fire page view or session_start when consent is none', () => { @@ -169,9 +169,9 @@ describe('Pixel', () => { expect.objectContaining({ type: 'page', surface: 'pixel', + sessionId: 'session-abc', properties: expect.objectContaining({ utm_source: 'google', - session_id: 'session-abc', custom: 'prop', }), }), @@ -268,6 +268,24 @@ describe('Pixel', () => { }); }); + describe('session id', () => { + it('stamps sessionId at the top level on both page and track (session_start) messages', () => { + // Default mock (isNew: true) means init() fires both a page view and a session_start track event. + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_imapik-test-local', consent: 'anonymous' }); + + const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record)); + const pageCall = calls.find((c) => c.type === 'page'); + const trackCall = calls.find((c) => c.type === 'track'); + + expect(pageCall).toBeDefined(); + expect(trackCall).toBeDefined(); + expect(pageCall!.sessionId).toBe('session-abc'); + expect(trackCall!.sessionId).toBe('session-abc'); + }); + }); + describe('session_end', () => { it('fires session_end on pagehide when session is active', () => { const pixel = new Pixel(); @@ -282,9 +300,7 @@ describe('Pixel', () => { expect.objectContaining({ type: 'track', eventName: 'session_end', - properties: expect.objectContaining({ - session_id: 'session-abc', - }), + sessionId: 'session-abc', }), ); }); @@ -605,9 +621,9 @@ describe('Pixel', () => { type: 'track', eventName: 'form_submitted', surface: 'pixel', + sessionId: 'session-xyz', properties: expect.objectContaining({ form_action: '/signup', - session_id: 'session-xyz', }), }), ); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index bb9ed64398..fb254a329e 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -152,7 +152,6 @@ export class Pixel { properties: { ...attribution, ...thirdPartyIds, - session_id: sessionId, ...properties, }, userId: undefined, @@ -230,7 +229,7 @@ export class Pixel { ...this.buildBase(), type: 'track', eventName, - properties: { ...properties, session_id: sessionId }, + properties, userId: undefined, }; @@ -243,18 +242,17 @@ export class Pixel { this.sessionId = sessionId; if (isNew) { this.sessionStartTime = Date.now(); - this.fireSessionStart(sessionId); + this.fireSessionStart(); } } - private fireSessionStart(sessionId: string): void { + private fireSessionStart(): void { if (!this.isTrackingAllowed()) return; const message: TrackMessage = { ...this.buildBase(), type: 'track', eventName: 'session_start', - properties: { session_id: sessionId }, userId: undefined, }; @@ -272,10 +270,7 @@ export class Pixel { ...this.buildBase(), type: 'track', eventName: 'session_end', - properties: { - session_id: this.sessionId, - duration, - }, + properties: { duration }, userId: undefined, }; @@ -333,6 +328,7 @@ export class Pixel { surface: 'pixel' as const, context: collectContext('@imtbl/pixel', PIXEL_VERSION), consentLevel: this.consent!.level, + sessionId: this.sessionId, ...(this.testMode && { test: true as const }), }; } diff --git a/packages/audience/sdk-sample-app/sample-app.js b/packages/audience/sdk-sample-app/sample-app.js index aa26bc14a6..7e41498323 100644 --- a/packages/audience/sdk-sample-app/sample-app.js +++ b/packages/audience/sdk-sample-app/sample-app.js @@ -269,7 +269,7 @@ currentAnonId = payload.anonymousId; changed = true; } - var sid = payload.properties && payload.properties.session_id; + var sid = payload.sessionId; if (typeof sid === 'string' && sid && currentSessionId !== sid) { currentSessionId = sid; changed = true; diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index 5f46c4ba59..b2304da73d 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -274,7 +274,7 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === SESSION_START, ); expect(msg).toBeDefined(); - expect(msg.properties).toHaveProperty('session_id'); + expect(msg).toHaveProperty('sessionId'); sdk.shutdown(); }); @@ -300,7 +300,7 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === SESSION_START, ); expect(msg).toBeDefined(); - expect(msg.properties).toHaveProperty('session_id'); + expect(msg).toHaveProperty('sessionId'); expect(msg.properties).toHaveProperty('utm_source', 'youtube'); expect(msg.properties).toHaveProperty('utm_campaign', 'launch'); @@ -347,8 +347,8 @@ describe('Audience', () => { ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), currency: 'USD', value: 9.99, item_id: 'sword_01', @@ -410,8 +410,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'sign_up', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), method: 'email', }); @@ -432,8 +432,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'game_launch', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), platform: 'webgl', version: '1.2.0', build_id: 'ci-42', @@ -459,8 +459,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'progression', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), status: 'complete', world: 'forest', level: '5', @@ -485,8 +485,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'achievement_unlocked', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), achievement_id: 'first_win', achievement_name: 'First Win', }); @@ -508,8 +508,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'achievement_unlocked', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), achievement_id: 'tutorial_done', achievement_name: 'Tutorial Complete', achievement_type: 'onboarding', @@ -534,8 +534,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'resource', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), flow: 'source', currency: 'gold', amount: 50, @@ -560,8 +560,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'wishlist_add', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), game_id: 'devilfish', source: 'game_page', platform: 'steam', @@ -592,8 +592,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'sign_up', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), utm_source: 'google', utm_campaign: 'spring', touchpoint_type: 'click', @@ -625,8 +625,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'link_clicked', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), utm_source: 'twitter', touchpoint_type: 'click', url: 'https://store.com', @@ -658,8 +658,8 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'purchase', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), currency: 'USD', value: 9.99, }); @@ -677,13 +677,43 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'game_launch', ); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), platform: 'webgl', }); sdk.shutdown(); }); + + it('picks up a session rollover on the next call instead of keeping a stale id', async () => { + const sdk = createSDK(); + + sdk.track('purchase', { currency: 'USD', value: 1 }); + await sdk.flush(); + const firstSessionId = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ).sessionId; + + // Simulate the session cookie expiring (30 min idle) before the next call. + document.cookie = `${SESSION_COOKIE}=;max-age=0;path=/`; + + sdk.track('purchase', { currency: 'USD', value: 2 }); + await sdk.flush(); + + const msgs = sentMessages(); + const sessionStarts = msgs.filter( + (m: any) => m.type === 'track' && m.eventName === SESSION_START, + ); + const purchases = msgs.filter( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + + expect(sessionStarts).toHaveLength(2); + expect(purchases[1].sessionId).not.toBe(firstSessionId); + expect(purchases[1].sessionId).toBe(sessionStarts[1].sessionId); + + sdk.shutdown(); + }); }); describe('page', () => { @@ -696,8 +726,8 @@ describe('Audience', () => { const msg = sentMessages().find((m: any) => m.type === 'page'); expect(msg).toBeDefined(); // First page merges session-cached attribution (includes landing_page) + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), landing_page: expect.any(String), section: 'shop', }); @@ -785,8 +815,8 @@ describe('Audience', () => { const msg = sentMessages().find((m: any) => m.type === 'page'); expect(msg).toBeDefined(); + expect(msg.sessionId).toEqual(expect.any(String)); expect(msg.properties).toEqual({ - session_id: expect.any(String), landing_page: expect.any(String), }); @@ -844,6 +874,30 @@ describe('Audience', () => { }); }); + describe('session id', () => { + it('stamps sessionId at the top level on every message type, including identify and alias', async () => { + const sdk = createSDK({ consent: 'full' }); + sdk.page(); + sdk.track('purchase', { currency: 'USD', value: 1 }); + sdk.identify(TEST_USER.id, TEST_USER.identityType); + sdk.alias(TEST_STEAM, TEST_USER); + await sdk.flush(); + + const msgs = sentMessages(); + expect(msgs.length).toBeGreaterThan(0); + msgs.forEach((m: any) => expect(m.sessionId).toEqual(expect.any(String))); + + const pageMsg = msgs.find((m: any) => m.type === 'page'); + const idMsg = msgs.find((m: any) => m.type === 'identify'); + const aliasMsg = msgs.find((m: any) => m.type === 'alias'); + // identify/alias have no `properties` field, so sessionId is only ever visible at the top level. + expect(idMsg.sessionId).toBe(pageMsg.sessionId); + expect(aliasMsg.sessionId).toBe(pageMsg.sessionId); + + sdk.shutdown(); + }); + }); + describe('identify', () => { it('sends an identify message at full consent', async () => { const sdk = createSDK({ consent: 'full' }); @@ -1038,7 +1092,7 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === SESSION_START, ); expect(sessionStart).toBeDefined(); - expect(sessionStart.properties).toHaveProperty('session_id'); + expect(sessionStart).toHaveProperty('sessionId'); const signUp = msgs.find( (m: any) => m.type === 'track' && m.eventName === 'sign_up', @@ -1159,7 +1213,7 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === SESSION_END, ); expect(msg).toBeDefined(); - expect(msg.properties).toHaveProperty('session_id'); + expect(msg).toHaveProperty('sessionId'); expect(msg.properties.duration).toBe(5); }); diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index 7bd821adde..650b4bc8aa 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -213,6 +213,16 @@ export class Audience { return session.isNew; } + // Unlike startSession(), fires session_start directly: callers here always already have a queue. + private refreshSession(): void { + const session = getOrCreateSession(this.cookieDomain); + this.sessionId = session.sessionId; + if (session.isNew) { + this.sessionStartTime = Date.now(); + this.trackSessionStart(); + } + } + // --- Message factory --- /** Common fields shared by every message. */ @@ -224,6 +234,7 @@ export class Audience { surface: 'web' as const, context: collectContext(LIBRARY_NAME, LIBRARY_VERSION), consentLevel: this.consent.level, + sessionId: this.sessionId, ...(this.testMode && { test: true as const }), }; } @@ -241,10 +252,7 @@ export class Audience { ...this.baseMessage(), type: 'track', eventName: SESSION_START, - properties: { - session_id: this.sessionId, - ...this.attribution, - }, + properties: { ...this.attribution }, }); } @@ -255,7 +263,6 @@ export class Audience { type: 'track', eventName: SESSION_END, properties: { - session_id: this.sessionId, ...(this.sessionStartTime && { duration: Math.round((Date.now() - this.sessionStartTime) / 1000), }), @@ -273,12 +280,9 @@ export class Audience { */ page(properties?: Record): void { if (this.isTrackingDisabled()) return; - getOrCreateSession(this.cookieDomain); + this.refreshSession(); - const mergedProps: Record = { - session_id: this.sessionId, - ...properties, - }; + const mergedProps: Record = { ...properties }; if (this.isFirstPage) { Object.assign(mergedProps, this.attribution); this.isFirstPage = false; @@ -312,11 +316,10 @@ export class Audience { : [properties: PropsFor] ): void { if (this.isTrackingDisabled()) return; - getOrCreateSession(this.cookieDomain); + this.refreshSession(); const [properties] = args; const mergedProps: Record = { - session_id: this.sessionId, ...(UTM_EVENTS.has(event) ? collectPageAttribution() : {}), ...properties as Record | undefined, }; @@ -346,7 +349,7 @@ export class Audience { this.debug.logWarning('identify() requires full consent — call ignored.'); return; } - getOrCreateSession(this.cookieDomain); + this.refreshSession(); const resolvedId = truncate(id); this.userId = resolvedId; @@ -379,7 +382,7 @@ export class Audience { this.debug.logWarning('alias() from and to are identical — call ignored.'); return; } - getOrCreateSession(this.cookieDomain); + this.refreshSession(); this.enqueue('alias', { ...this.baseMessage(),