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(),