diff --git a/packages/audience/core/src/attribution.test.ts b/packages/audience/core/src/attribution.test.ts index b742260a35..033675ddcd 100644 --- a/packages/audience/core/src/attribution.test.ts +++ b/packages/audience/core/src/attribution.test.ts @@ -1,4 +1,4 @@ -import { collectAttribution, clearAttribution } from './attribution'; +import { collectSessionAttribution, collectPageAttribution, clearAttribution } from './attribution'; const STORAGE_KEY = '__imtbl_attribution'; @@ -15,13 +15,13 @@ function setLocation(url: string) { }); } -describe('collectAttribution', () => { +describe('collectSessionAttribution', () => { it('parses UTM parameters from the URL', () => { setLocation( 'https://example.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_content=banner&utm_term=nft', ); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.utm_source).toBe('google'); expect(result.utm_medium).toBe('cpc'); expect(result.utm_campaign).toBe('spring'); @@ -34,7 +34,7 @@ describe('collectAttribution', () => { 'https://example.com/?gclid=abc&dclid=dc1&fbclid=fb2&ttclid=tt3&msclkid=ms4&li_fat_id=li5', ); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.gclid).toBe('abc'); expect(result.dclid).toBe('dc1'); expect(result.fbclid).toBe('fb2'); @@ -50,7 +50,7 @@ describe('collectAttribution', () => { configurable: true, }); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.referrer).toBe('https://google.com/search?q=nft'); expect(result.landing_page).toBe('https://game.example.com/landing'); }); @@ -58,33 +58,33 @@ describe('collectAttribution', () => { it('caches in sessionStorage and returns cached on second call', () => { setLocation('https://example.com/?utm_source=google'); - const first = collectAttribution(); + const first = collectSessionAttribution(); expect(first.utm_source).toBe('google'); // Change URL — should still return cached value setLocation('https://example.com/?utm_source=facebook'); - const second = collectAttribution(); + const second = collectSessionAttribution(); expect(second.utm_source).toBe('google'); }); it('parses referral_code from the URL', () => { setLocation('https://example.com/?referral_code=PARTNER42'); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.referral_code).toBe('PARTNER42'); }); it('sets touchpoint_type to click when UTMs are present', () => { setLocation('https://example.com/?utm_source=google'); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.touchpoint_type).toBe('click'); }); it('sets touchpoint_type to click when a click ID is present', () => { setLocation('https://example.com/?gclid=abc123'); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.touchpoint_type).toBe('click'); }); @@ -92,7 +92,7 @@ describe('collectAttribution', () => { setLocation('https://example.com/'); Object.defineProperty(document, 'referrer', { value: 'https://other.com', configurable: true }); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.touchpoint_type).toBeUndefined(); }); @@ -100,7 +100,7 @@ describe('collectAttribution', () => { setLocation('https://example.com/'); Object.defineProperty(document, 'referrer', { value: '', configurable: true }); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.utm_source).toBeUndefined(); expect(result.gclid).toBeUndefined(); expect(result.referrer).toBeUndefined(); @@ -115,15 +115,61 @@ describe('collectAttribution', () => { throw new Error('storage disabled'); }); - const result = collectAttribution(); + const result = collectSessionAttribution(); expect(result.utm_source).toBe('twitter'); }); }); +describe('collectPageAttribution', () => { + it('always parses from the current URL, ignoring sessionStorage', () => { + setLocation('https://example.com/?utm_source=google'); + collectSessionAttribution(); // seeds sessionStorage + + // Change URL — collectSessionAttribution would return cached 'google', + // but collectPageAttribution reads the new URL. + setLocation('https://example.com/?utm_source=facebook'); + const result = collectPageAttribution(); + expect(result.utm_source).toBe('facebook'); + }); + + it('does not write to sessionStorage', () => { + setLocation('https://example.com/?utm_source=twitter'); + + collectPageAttribution(); + expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull(); + }); + + it('does not include landing_page', () => { + setLocation('https://example.com/?utm_source=google'); + + const result = collectPageAttribution(); + expect(result.utm_source).toBe('google'); + expect(result.landing_page).toBeUndefined(); + }); + + it('sets touchpoint_type to click when UTMs are present', () => { + setLocation('https://example.com/?utm_source=google'); + + const result = collectPageAttribution(); + expect(result.touchpoint_type).toBe('click'); + }); + + it('captures referrer', () => { + setLocation('https://example.com/'); + Object.defineProperty(document, 'referrer', { + value: 'https://google.com/', + configurable: true, + }); + + const result = collectPageAttribution(); + expect(result.referrer).toBe('https://google.com/'); + }); +}); + describe('clearAttribution', () => { it('removes cached attribution from sessionStorage', () => { setLocation('https://example.com/?utm_source=google'); - collectAttribution(); + collectSessionAttribution(); expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull(); clearAttribution(); diff --git a/packages/audience/core/src/attribution.ts b/packages/audience/core/src/attribution.ts index 0046ba054d..d6264bbddc 100644 --- a/packages/audience/core/src/attribution.ts +++ b/packages/audience/core/src/attribution.ts @@ -78,33 +78,49 @@ function saveToStorage(attribution: Attribution): void { } } -export function collectAttribution(): Attribution { - const cached = loadFromStorage(); - if (cached) return cached; - +function buildAttribution(): Attribution { const urlParams = typeof window !== 'undefined' && window.location ? parseParams(window.location.href) : {}; const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined; - const landingPage = typeof window !== 'undefined' && window.location - ? window.location.href - : undefined; const hasClickId = CLICK_ID_PARAMS.some((key) => key in urlParams); const hasUtm = UTM_PARAMS.some((key) => key in urlParams); - const attribution: Attribution = { + return { ...urlParams, referrer, - landing_page: landingPage, touchpoint_type: hasClickId || hasUtm ? 'click' : undefined, }; +} + +export function collectSessionAttribution(): Attribution { + const cached = loadFromStorage(); + if (cached) return cached; + + const landingPage = typeof window !== 'undefined' && window.location + ? window.location.href + : undefined; + + const attribution: Attribution = { + ...buildAttribution(), + landing_page: landingPage, + }; saveToStorage(attribution); return attribution; } +/** + * Parse attribution from the current URL without reading or writing + * sessionStorage. Returns the UTM / click-ID params on the URL right + * now, not the ones cached at session start. + */ +export function collectPageAttribution(): Attribution { + return buildAttribution(); +} + export function clearAttribution(): void { try { sessionStorage.removeItem(STORAGE_KEY); diff --git a/packages/audience/core/src/index.ts b/packages/audience/core/src/index.ts index f7404a93e8..0af3ff284a 100644 --- a/packages/audience/core/src/index.ts +++ b/packages/audience/core/src/index.ts @@ -41,7 +41,7 @@ export { isTimestampValid, isAliasValid, truncate } from './validation'; export { getOrCreateSession } from './session'; export type { SessionResult } from './session'; -export { collectAttribution } from './attribution'; +export { collectSessionAttribution, collectPageAttribution } from './attribution'; export type { Attribution } from './attribution'; export { diff --git a/packages/audience/pixel/src/bootstrap.test.ts b/packages/audience/pixel/src/bootstrap.test.ts index bb8d7778b7..3d031a16b9 100644 --- a/packages/audience/pixel/src/bootstrap.test.ts +++ b/packages/audience/pixel/src/bootstrap.test.ts @@ -27,7 +27,7 @@ jest.mock('@imtbl/audience-core', () => ({ isBrowser: jest.fn().mockReturnValue(true), getCookie: jest.fn(), setCookie: jest.fn(), - collectAttribution: jest.fn().mockReturnValue({}), + collectSessionAttribution: jest.fn().mockReturnValue({}), getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 's', isNew: false }), createConsentManager: jest.fn().mockReturnValue({ level: 'none', setLevel: jest.fn() }), })); diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index a2dd053576..bef2e9d29e 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -44,7 +44,7 @@ jest.mock('@imtbl/audience-core', () => ({ isBrowser: jest.fn().mockReturnValue(true), getCookie: jest.fn(), setCookie: jest.fn(), - collectAttribution: jest.fn().mockReturnValue({ + collectSessionAttribution: jest.fn().mockReturnValue({ utm_source: 'google', landing_page: 'https://example.com', }), diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index 79c9edf42d..a3b3f433b9 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -14,7 +14,7 @@ import { getTimestamp, isBrowser, getCookie, - collectAttribution, + collectSessionAttribution, getOrCreateSession, createConsentManager, canTrack, @@ -132,7 +132,7 @@ export class Pixel { const { sessionId, isNew } = getOrCreateSession(this.domain); this.refreshSession(sessionId, isNew); - const attribution = collectAttribution(); + const attribution = collectSessionAttribution(); const thirdPartyIds = this.collectThirdPartyIds(); const message: PageMessage = { diff --git a/packages/audience/sdk/jest.config.ts b/packages/audience/sdk/jest.config.ts index 323ad11b09..499e8aa989 100644 --- a/packages/audience/sdk/jest.config.ts +++ b/packages/audience/sdk/jest.config.ts @@ -9,6 +9,10 @@ const config: Config = { }, moduleNameMapper: { '^@imtbl/audience-core$': '/../core/src/index.ts', + // The core source code imports other @imtbl packages (e.g. metrics). + // This tells jest where to find them so we can run tests without + // building the whole monorepo first. + '^@imtbl/(.*)$': '/../../../node_modules/@imtbl/$1/src', }, }; diff --git a/packages/audience/sdk/src/sdk.test.ts b/packages/audience/sdk/src/sdk.test.ts index d2737fb8a0..a36ac07a20 100644 --- a/packages/audience/sdk/src/sdk.test.ts +++ b/packages/audience/sdk/src/sdk.test.ts @@ -184,6 +184,7 @@ describe('Audience', () => { expect(msg).toBeDefined(); expect(msg.properties).toEqual({ + sessionId: expect.any(String), currency: 'USD', value: 9.99, itemId: 'sword_01', @@ -235,7 +236,7 @@ describe('Audience', () => { sdk.shutdown(); }); - it('enqueues sign_up with method property', async () => { + it('enqueues sign_up with method property and sessionId', async () => { const sdk = createSDK(); sdk.track('sign_up', { method: 'email' }); @@ -245,7 +246,10 @@ describe('Audience', () => { (m: any) => m.type === 'track' && m.eventName === 'sign_up', ); expect(msg).toBeDefined(); - expect(msg.properties).toEqual({ method: 'email' }); + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + method: 'email', + }); sdk.shutdown(); }); @@ -265,6 +269,7 @@ describe('Audience', () => { ); expect(msg).toBeDefined(); expect(msg.properties).toEqual({ + sessionId: expect.any(String), platform: 'webgl', version: '1.2.0', buildId: 'ci-42', @@ -291,6 +296,7 @@ describe('Audience', () => { ); expect(msg).toBeDefined(); expect(msg.properties).toEqual({ + sessionId: expect.any(String), status: 'complete', world: 'forest', level: '5', @@ -319,6 +325,7 @@ describe('Audience', () => { ); expect(msg).toBeDefined(); expect(msg.properties).toEqual({ + sessionId: expect.any(String), flow: 'source', currency: 'gold', amount: 50, @@ -344,6 +351,7 @@ describe('Audience', () => { ); expect(msg).toBeDefined(); expect(msg.properties).toEqual({ + sessionId: expect.any(String), gameId: 'devilfish', source: 'game_page', platform: 'steam', @@ -351,6 +359,121 @@ describe('Audience', () => { sdk.shutdown(); }); + + it('attaches UTM attribution to sign_up events', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=google&utm_campaign=spring', + href: 'https://studio.com/?utm_source=google&utm_campaign=spring', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK(); + sdk.track('sign_up', { method: 'passport' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'sign_up', + ); + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + utm_source: 'google', + utm_campaign: 'spring', + touchpoint_type: 'click', + method: 'passport', + }); + + sdk.shutdown(); + }); + + it('attaches UTM attribution to link_clicked events', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=twitter', + href: 'https://studio.com/?utm_source=twitter', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK(); + sdk.track('link_clicked', { url: 'https://store.com', label: 'Buy' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'link_clicked', + ); + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + utm_source: 'twitter', + touchpoint_type: 'click', + url: 'https://store.com', + label: 'Buy', + }); + + sdk.shutdown(); + }); + + it('does not attach UTM attribution to other track events', async () => { + Object.defineProperty(window, 'location', { + value: { + ...window.location, + search: '?utm_source=google', + href: 'https://studio.com/?utm_source=google', + protocol: 'https:', + pathname: '/', + }, + writable: true, + configurable: true, + }); + sessionStorage.clear(); + + const sdk = createSDK(); + sdk.track('purchase', { currency: 'USD', value: 9.99 }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'purchase', + ); + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + currency: 'USD', + value: 9.99, + }); + + sdk.shutdown(); + }); + + it('includes sessionId on all track events', async () => { + const sdk = createSDK(); + + sdk.track('game_launch', { platform: 'webgl' }); + await sdk.flush(); + + const msg = sentMessages().find( + (m: any) => m.type === 'track' && m.eventName === 'game_launch', + ); + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + platform: 'webgl', + }); + + sdk.shutdown(); + }); }); describe('page', () => { @@ -362,7 +485,12 @@ describe('Audience', () => { const msg = sentMessages().find((m: any) => m.type === 'page'); expect(msg).toBeDefined(); - expect(msg.properties).toMatchObject({ section: 'shop' }); + // First page merges session-cached attribution (includes landing_page) + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + landing_page: expect.any(String), + section: 'shop', + }); sdk.shutdown(); }); @@ -406,7 +534,7 @@ describe('Audience', () => { sdk.shutdown(); }); - it('attaches attribution to the first page view', async () => { + it('attaches attribution to the first page view only', async () => { Object.defineProperty(window, 'location', { value: { ...window.location, @@ -438,6 +566,22 @@ describe('Audience', () => { sdk.shutdown(); }); + + it('includes sessionId in page properties', async () => { + const sdk = createSDK(); + + sdk.page(); + await sdk.flush(); + + const msg = sentMessages().find((m: any) => m.type === 'page'); + expect(msg).toBeDefined(); + expect(msg.properties).toEqual({ + sessionId: expect.any(String), + landing_page: expect.any(String), + }); + + sdk.shutdown(); + }); }); describe('identify', () => { diff --git a/packages/audience/sdk/src/sdk.ts b/packages/audience/sdk/src/sdk.ts index 74bbd61179..42837610b0 100644 --- a/packages/audience/sdk/src/sdk.ts +++ b/packages/audience/sdk/src/sdk.ts @@ -20,7 +20,8 @@ import { isTimestampValid, truncate, collectContext, - collectAttribution, + collectSessionAttribution, + collectPageAttribution, getOrCreateSession, createConsentManager, canTrack, @@ -35,6 +36,11 @@ import { LIBRARY_NAME, LIBRARY_VERSION, LOG_PREFIX, DEFAULT_CONSENT_SOURCE, } from './config'; +/** Track events that carry UTM attribution from the current page URL. */ +const UTM_EVENTS: ReadonlySet = new Set([ + 'sign_up', 'link_clicked', +]); + /** * Track player activity on your website — page views, purchases, sign-ups — * and tie it to player identity when they log in. @@ -110,7 +116,7 @@ export class Audience { config.baseUrl, ); - this.attribution = collectAttribution(); + this.attribution = collectSessionAttribution(); if (!this.isTrackingDisabled()) { this.queue.start(); @@ -209,14 +215,18 @@ export class Audience { /** * 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'. + * The first call captures how the player arrived (UTM params, ad click + * IDs, referrer). Every call includes the session ID. + * No-op when consent is 'none'. */ page(properties?: Record): void { if (this.isTrackingDisabled()) return; getOrCreateSession(this.cookieDomain); - const mergedProps: Record = { ...properties }; + const mergedProps: Record = { + sessionId: this.sessionId, + ...properties, + }; if (this.isFirstPage) { Object.assign(mergedProps, this.attribution); this.isFirstPage = false; @@ -225,7 +235,7 @@ export class Audience { this.enqueue('page', { ...this.baseMessage(), type: 'page', - properties: Object.keys(mergedProps).length > 0 ? mergedProps : undefined, + properties: mergedProps, userId: this.effectiveUserId(), }); } @@ -237,6 +247,10 @@ export class Audience { * Pass the event name and any properties you want to analyse later. * For the predefined event names (`sign_up`, `purchase`, etc.), * TypeScript enforces the property shape at the call site. + * + * `sign_up` and `link_clicked` events automatically include UTM + * attribution from the current page URL. All events include `sessionId`. + * * No-op when consent is 'none'. */ track( @@ -249,11 +263,17 @@ export class Audience { getOrCreateSession(this.cookieDomain); const [properties] = args; + const mergedProps: Record = { + sessionId: this.sessionId, + ...(UTM_EVENTS.has(event) ? collectPageAttribution() : {}), + ...properties as Record | undefined, + }; + this.enqueue('track', { ...this.baseMessage(), type: 'track', eventName: truncate(event), - properties: properties as Record | undefined, + properties: mergedProps, userId: this.effectiveUserId(), }); }