Skip to content

Commit 0af222f

Browse files
feat(audience): sessionId on all events, UTMs on sign_up and link_clicked (SDK-140)
- Add collectPageAttribution to core (parses current URL, no sessionStorage cache) - Extract shared buildAttribution helper to deduplicate collection logic - Rename collectAttribution → collectSessionAttribution, collectFreshAttribution → collectPageAttribution - page(): include sessionId in properties, remove dead empty-check guard - track(): include sessionId in properties - track(): merge fresh UTM attribution for sign_up and link_clicked only - Fix @imtbl/metrics resolution in SDK jest config Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 385cac4 commit 0af222f

File tree

9 files changed

+269
-39
lines changed

9 files changed

+269
-39
lines changed

packages/audience/core/src/attribution.test.ts

Lines changed: 60 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { collectAttribution, clearAttribution } from './attribution';
1+
import { collectSessionAttribution, collectPageAttribution, clearAttribution } from './attribution';
22

33
const STORAGE_KEY = '__imtbl_attribution';
44

@@ -15,13 +15,13 @@ function setLocation(url: string) {
1515
});
1616
}
1717

18-
describe('collectAttribution', () => {
18+
describe('collectSessionAttribution', () => {
1919
it('parses UTM parameters from the URL', () => {
2020
setLocation(
2121
'https://example.com/?utm_source=google&utm_medium=cpc&utm_campaign=spring&utm_content=banner&utm_term=nft',
2222
);
2323

24-
const result = collectAttribution();
24+
const result = collectSessionAttribution();
2525
expect(result.utm_source).toBe('google');
2626
expect(result.utm_medium).toBe('cpc');
2727
expect(result.utm_campaign).toBe('spring');
@@ -34,7 +34,7 @@ describe('collectAttribution', () => {
3434
'https://example.com/?gclid=abc&dclid=dc1&fbclid=fb2&ttclid=tt3&msclkid=ms4&li_fat_id=li5',
3535
);
3636

37-
const result = collectAttribution();
37+
const result = collectSessionAttribution();
3838
expect(result.gclid).toBe('abc');
3939
expect(result.dclid).toBe('dc1');
4040
expect(result.fbclid).toBe('fb2');
@@ -50,57 +50,57 @@ describe('collectAttribution', () => {
5050
configurable: true,
5151
});
5252

53-
const result = collectAttribution();
53+
const result = collectSessionAttribution();
5454
expect(result.referrer).toBe('https://google.com/search?q=nft');
5555
expect(result.landing_page).toBe('https://game.example.com/landing');
5656
});
5757

5858
it('caches in sessionStorage and returns cached on second call', () => {
5959
setLocation('https://example.com/?utm_source=google');
6060

61-
const first = collectAttribution();
61+
const first = collectSessionAttribution();
6262
expect(first.utm_source).toBe('google');
6363

6464
// Change URL — should still return cached value
6565
setLocation('https://example.com/?utm_source=facebook');
66-
const second = collectAttribution();
66+
const second = collectSessionAttribution();
6767
expect(second.utm_source).toBe('google');
6868
});
6969

7070
it('parses referral_code from the URL', () => {
7171
setLocation('https://example.com/?referral_code=PARTNER42');
7272

73-
const result = collectAttribution();
73+
const result = collectSessionAttribution();
7474
expect(result.referral_code).toBe('PARTNER42');
7575
});
7676

7777
it('sets touchpoint_type to click when UTMs are present', () => {
7878
setLocation('https://example.com/?utm_source=google');
7979

80-
const result = collectAttribution();
80+
const result = collectSessionAttribution();
8181
expect(result.touchpoint_type).toBe('click');
8282
});
8383

8484
it('sets touchpoint_type to click when a click ID is present', () => {
8585
setLocation('https://example.com/?gclid=abc123');
8686

87-
const result = collectAttribution();
87+
const result = collectSessionAttribution();
8888
expect(result.touchpoint_type).toBe('click');
8989
});
9090

9191
it('does not set touchpoint_type when no UTMs or click IDs are present', () => {
9292
setLocation('https://example.com/');
9393
Object.defineProperty(document, 'referrer', { value: 'https://other.com', configurable: true });
9494

95-
const result = collectAttribution();
95+
const result = collectSessionAttribution();
9696
expect(result.touchpoint_type).toBeUndefined();
9797
});
9898

9999
it('returns empty attribution when no params are present', () => {
100100
setLocation('https://example.com/');
101101
Object.defineProperty(document, 'referrer', { value: '', configurable: true });
102102

103-
const result = collectAttribution();
103+
const result = collectSessionAttribution();
104104
expect(result.utm_source).toBeUndefined();
105105
expect(result.gclid).toBeUndefined();
106106
expect(result.referrer).toBeUndefined();
@@ -115,15 +115,61 @@ describe('collectAttribution', () => {
115115
throw new Error('storage disabled');
116116
});
117117

118-
const result = collectAttribution();
118+
const result = collectSessionAttribution();
119119
expect(result.utm_source).toBe('twitter');
120120
});
121121
});
122122

123+
describe('collectPageAttribution', () => {
124+
it('always parses from the current URL, ignoring sessionStorage', () => {
125+
setLocation('https://example.com/?utm_source=google');
126+
collectSessionAttribution(); // seeds sessionStorage
127+
128+
// Change URL — collectSessionAttribution would return cached 'google',
129+
// but collectPageAttribution reads the new URL.
130+
setLocation('https://example.com/?utm_source=facebook');
131+
const result = collectPageAttribution();
132+
expect(result.utm_source).toBe('facebook');
133+
});
134+
135+
it('does not write to sessionStorage', () => {
136+
setLocation('https://example.com/?utm_source=twitter');
137+
138+
collectPageAttribution();
139+
expect(sessionStorage.getItem(STORAGE_KEY)).toBeNull();
140+
});
141+
142+
it('does not include landing_page', () => {
143+
setLocation('https://example.com/?utm_source=google');
144+
145+
const result = collectPageAttribution();
146+
expect(result.utm_source).toBe('google');
147+
expect(result.landing_page).toBeUndefined();
148+
});
149+
150+
it('sets touchpoint_type to click when UTMs are present', () => {
151+
setLocation('https://example.com/?utm_source=google');
152+
153+
const result = collectPageAttribution();
154+
expect(result.touchpoint_type).toBe('click');
155+
});
156+
157+
it('captures referrer', () => {
158+
setLocation('https://example.com/');
159+
Object.defineProperty(document, 'referrer', {
160+
value: 'https://google.com/',
161+
configurable: true,
162+
});
163+
164+
const result = collectPageAttribution();
165+
expect(result.referrer).toBe('https://google.com/');
166+
});
167+
});
168+
123169
describe('clearAttribution', () => {
124170
it('removes cached attribution from sessionStorage', () => {
125171
setLocation('https://example.com/?utm_source=google');
126-
collectAttribution();
172+
collectSessionAttribution();
127173
expect(sessionStorage.getItem(STORAGE_KEY)).not.toBeNull();
128174

129175
clearAttribution();

packages/audience/core/src/attribution.ts

Lines changed: 25 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -78,33 +78,49 @@ function saveToStorage(attribution: Attribution): void {
7878
}
7979
}
8080

81-
export function collectAttribution(): Attribution {
82-
const cached = loadFromStorage();
83-
if (cached) return cached;
84-
81+
function buildAttribution(): Attribution {
8582
const urlParams = typeof window !== 'undefined' && window.location
8683
? parseParams(window.location.href)
8784
: {};
8885

8986
const referrer = typeof document !== 'undefined' ? document.referrer || undefined : undefined;
90-
const landingPage = typeof window !== 'undefined' && window.location
91-
? window.location.href
92-
: undefined;
9387

9488
const hasClickId = CLICK_ID_PARAMS.some((key) => key in urlParams);
9589
const hasUtm = UTM_PARAMS.some((key) => key in urlParams);
9690

97-
const attribution: Attribution = {
91+
return {
9892
...urlParams,
9993
referrer,
100-
landing_page: landingPage,
10194
touchpoint_type: hasClickId || hasUtm ? 'click' : undefined,
10295
};
96+
}
97+
98+
export function collectSessionAttribution(): Attribution {
99+
const cached = loadFromStorage();
100+
if (cached) return cached;
101+
102+
const landingPage = typeof window !== 'undefined' && window.location
103+
? window.location.href
104+
: undefined;
105+
106+
const attribution: Attribution = {
107+
...buildAttribution(),
108+
landing_page: landingPage,
109+
};
103110

104111
saveToStorage(attribution);
105112
return attribution;
106113
}
107114

115+
/**
116+
* Parse attribution from the current URL without reading or writing
117+
* sessionStorage. Returns the UTM / click-ID params on the URL right
118+
* now, not the ones cached at session start.
119+
*/
120+
export function collectPageAttribution(): Attribution {
121+
return buildAttribution();
122+
}
123+
108124
export function clearAttribution(): void {
109125
try {
110126
sessionStorage.removeItem(STORAGE_KEY);

packages/audience/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export { isTimestampValid, isAliasValid, truncate } from './validation';
4141
export { getOrCreateSession } from './session';
4242
export type { SessionResult } from './session';
4343

44-
export { collectAttribution } from './attribution';
44+
export { collectSessionAttribution, collectPageAttribution } from './attribution';
4545
export type { Attribution } from './attribution';
4646

4747
export {

packages/audience/pixel/src/bootstrap.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ jest.mock('@imtbl/audience-core', () => ({
2727
isBrowser: jest.fn().mockReturnValue(true),
2828
getCookie: jest.fn(),
2929
setCookie: jest.fn(),
30-
collectAttribution: jest.fn().mockReturnValue({}),
30+
collectSessionAttribution: jest.fn().mockReturnValue({}),
3131
getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 's', isNew: false }),
3232
createConsentManager: jest.fn().mockReturnValue({ level: 'none', setLevel: jest.fn() }),
3333
}));

packages/audience/pixel/src/pixel.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ jest.mock('@imtbl/audience-core', () => ({
4444
isBrowser: jest.fn().mockReturnValue(true),
4545
getCookie: jest.fn(),
4646
setCookie: jest.fn(),
47-
collectAttribution: jest.fn().mockReturnValue({
47+
collectSessionAttribution: jest.fn().mockReturnValue({
4848
utm_source: 'google',
4949
landing_page: 'https://example.com',
5050
}),

packages/audience/pixel/src/pixel.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
getTimestamp,
1515
isBrowser,
1616
getCookie,
17-
collectAttribution,
17+
collectSessionAttribution,
1818
getOrCreateSession,
1919
createConsentManager,
2020
canTrack,
@@ -132,7 +132,7 @@ export class Pixel {
132132

133133
const { sessionId, isNew } = getOrCreateSession(this.domain);
134134
this.refreshSession(sessionId, isNew);
135-
const attribution = collectAttribution();
135+
const attribution = collectSessionAttribution();
136136
const thirdPartyIds = this.collectThirdPartyIds();
137137

138138
const message: PageMessage = {

packages/audience/sdk/jest.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ const config: Config = {
99
},
1010
moduleNameMapper: {
1111
'^@imtbl/audience-core$': '<rootDir>/../core/src/index.ts',
12+
// The core source code imports other @imtbl packages (e.g. metrics).
13+
// This tells jest where to find them so we can run tests without
14+
// building the whole monorepo first.
15+
'^@imtbl/(.*)$': '<rootDir>/../../../node_modules/@imtbl/$1/src',
1216
},
1317
};
1418

0 commit comments

Comments
 (0)