Skip to content

Commit 7b06c8f

Browse files
bkboothclaude
andauthored
feat(audience): bootstrap wiring, GA/Meta IDs, unload fix (#2831)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 80fcd63 commit 7b06c8f

6 files changed

Lines changed: 233 additions & 4 deletions

File tree

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/* eslint-disable @typescript-eslint/no-require-imports, global-require */
2+
3+
// We need to control when bootstrap.ts runs, so we use dynamic require
4+
// after setting up the window stub and mocks.
5+
6+
const mockInit = jest.fn();
7+
const mockPage = jest.fn();
8+
const mockIdentify = jest.fn();
9+
const mockSetConsent = jest.fn();
10+
const mockDestroy = jest.fn();
11+
12+
jest.mock('./pixel', () => ({
13+
Pixel: jest.fn().mockImplementation(() => ({
14+
init: mockInit,
15+
page: mockPage,
16+
identify: mockIdentify,
17+
setConsent: mockSetConsent,
18+
destroy: mockDestroy,
19+
})),
20+
}));
21+
22+
jest.mock('@imtbl/audience-core', () => ({
23+
MessageQueue: jest.fn(),
24+
httpTransport: {},
25+
getBaseUrl: jest.fn(),
26+
INGEST_PATH: '',
27+
FLUSH_INTERVAL_MS: 5000,
28+
FLUSH_SIZE: 20,
29+
getOrCreateAnonymousId: jest.fn(),
30+
collectContext: jest.fn(),
31+
generateId: jest.fn(),
32+
getTimestamp: jest.fn(),
33+
isBrowser: jest.fn().mockReturnValue(true),
34+
getCookie: jest.fn(),
35+
setCookie: jest.fn(),
36+
collectAttribution: jest.fn().mockReturnValue({}),
37+
getOrCreateSession: jest.fn().mockReturnValue({ sessionId: 's', isNew: false }),
38+
createConsentManager: jest.fn().mockReturnValue({ level: 'none', setLevel: jest.fn() }),
39+
}));
40+
41+
beforeEach(() => {
42+
jest.clearAllMocks();
43+
delete (window as Record<string, unknown>).__imtbl;
44+
// Re-isolate module so the side-effect runs fresh
45+
jest.resetModules();
46+
});
47+
48+
describe('bootstrap', () => {
49+
it('replays queued init command from snippet stub', () => {
50+
// Simulate snippet having queued an init command
51+
(window as Record<string, unknown>).__imtbl = [
52+
['init', { key: 'pk_test', environment: 'dev', consent: 'anonymous' }],
53+
];
54+
55+
require('./bootstrap');
56+
57+
expect(mockInit).toHaveBeenCalledWith({
58+
key: 'pk_test',
59+
environment: 'dev',
60+
consent: 'anonymous',
61+
});
62+
});
63+
64+
it('replays multiple queued commands in order', () => {
65+
(window as Record<string, unknown>).__imtbl = [
66+
['init', { key: 'pk_test' }],
67+
['consent', 'full'],
68+
['identify', 'user-1', { email: 'a@b.com' }],
69+
];
70+
71+
require('./bootstrap');
72+
73+
expect(mockInit).toHaveBeenCalledWith({ key: 'pk_test' });
74+
expect(mockSetConsent).toHaveBeenCalledWith('full');
75+
expect(mockIdentify).toHaveBeenCalledWith('user-1', { email: 'a@b.com' });
76+
});
77+
78+
it('installs loader and handles new commands after load', () => {
79+
require('./bootstrap');
80+
81+
const loader = (window as Record<string, unknown>).__imtbl as {
82+
push: (...args: unknown[]) => void;
83+
_loaded: boolean;
84+
};
85+
86+
expect(loader._loaded).toBe(true);
87+
88+
loader.push(['page', { custom: 'prop' }]);
89+
expect(mockPage).toHaveBeenCalledWith({ custom: 'prop' });
90+
91+
loader.push(['consent', 'anonymous']);
92+
expect(mockSetConsent).toHaveBeenCalledWith('anonymous');
93+
});
94+
95+
it('ignores unknown commands', () => {
96+
(window as Record<string, unknown>).__imtbl = [
97+
['nonexistent', 'arg1'],
98+
];
99+
100+
// Should not throw
101+
expect(() => require('./bootstrap')).not.toThrow();
102+
});
103+
104+
it('works when no stub exists on window', () => {
105+
expect(() => require('./bootstrap')).not.toThrow();
106+
107+
const loader = (window as Record<string, unknown>).__imtbl as {
108+
_loaded: boolean;
109+
};
110+
expect(loader._loaded).toBe(true);
111+
});
112+
});
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
/**
2+
* Self-executing bootstrap that wires the command-queue loader to the Pixel.
3+
*
4+
* When the IIFE bundle loads, this module:
5+
* 1. Creates a Pixel singleton
6+
* 2. Maps command names to Pixel methods
7+
* 3. Installs the loader on window.__imtbl (replacing the stub array)
8+
* 4. Replays any commands the snippet queued before the script loaded
9+
*/
10+
import { Pixel } from './pixel';
11+
import { createLoader } from './loader';
12+
import type { Command } from './loader';
13+
14+
const pixel = new Pixel();
15+
16+
function handleCommand(command: Command): void {
17+
const [name, ...args] = command;
18+
19+
switch (name) {
20+
case 'init':
21+
pixel.init(args[0] as Parameters<Pixel['init']>[0]);
22+
break;
23+
case 'page':
24+
pixel.page(args[0] as Parameters<Pixel['page']>[0]);
25+
break;
26+
case 'identify':
27+
pixel.identify(
28+
args[0] as string,
29+
args[1] as Parameters<Pixel['identify']>[1],
30+
);
31+
break;
32+
case 'consent':
33+
pixel.setConsent(args[0] as Parameters<Pixel['setConsent']>[0]);
34+
break;
35+
default:
36+
// Unknown command — ignore silently
37+
break;
38+
}
39+
}
40+
41+
createLoader(handleCommand);
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
/**
2+
* IIFE entry point for the CDN bundle (dist/imtbl.js).
3+
*
4+
* Only imports the bootstrap side-effect — no development utilities
5+
* like snippet generator or createLoader are exposed, keeping the
6+
* bundle as small as possible.
7+
*/
8+
import './bootstrap';

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ jest.mock('@imtbl/audience-core', () => ({
5858
// Mock fetch globally
5959
global.fetch = jest.fn().mockResolvedValue({ ok: true });
6060

61+
const mockGetCookie = jest.requireMock('@imtbl/audience-core').getCookie as jest.Mock;
62+
6163
let activePixel: Pixel | null = null;
6264

6365
beforeEach(() => {
@@ -166,6 +168,47 @@ describe('Pixel', () => {
166168
pixel.page();
167169
expect(mockEnqueue).not.toHaveBeenCalled();
168170
});
171+
172+
it('includes GA and Meta cookies in page properties when present', () => {
173+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
174+
mockGetCookie.mockImplementation((name: string) => {
175+
const cookies: Record<string, string> = {
176+
_ga: 'GA1.2.123456.789012',
177+
_fbc: 'fb.1.1234567890.AbCdEf',
178+
_fbp: 'fb.1.1234567890.987654321',
179+
};
180+
return cookies[name];
181+
});
182+
183+
const pixel = new Pixel();
184+
activePixel = pixel;
185+
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
186+
187+
const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
188+
const pageCall = calls.find((c) => c.type === 'page');
189+
const props = pageCall!.properties as Record<string, unknown>;
190+
191+
expect(props.gaClientId).toBe('GA1.2.123456.789012');
192+
expect(props.fbClickId).toBe('fb.1.1234567890.AbCdEf');
193+
expect(props.fbBrowserId).toBe('fb.1.1234567890.987654321');
194+
});
195+
196+
it('omits third-party IDs when cookies are not set', () => {
197+
mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-abc', isNew: false });
198+
mockGetCookie.mockReturnValue(undefined);
199+
200+
const pixel = new Pixel();
201+
activePixel = pixel;
202+
pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' });
203+
204+
const calls = mockEnqueue.mock.calls.map((c: unknown[]) => (c[0] as Record<string, unknown>));
205+
const pageCall = calls.find((c) => c.type === 'page');
206+
const props = pageCall!.properties as Record<string, unknown>;
207+
208+
expect(props.gaClientId).toBeUndefined();
209+
expect(props.fbClickId).toBeUndefined();
210+
expect(props.fbBrowserId).toBeUndefined();
211+
});
169212
});
170213

171214
describe('identify', () => {

packages/audience/pixel/src/pixel.ts

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
generateId,
2020
getTimestamp,
2121
isBrowser,
22+
getCookie,
2223
collectAttribution,
2324
getOrCreateSession,
2425
createConsentManager,
@@ -96,15 +97,18 @@ export class Pixel {
9697
consentLevel,
9798
);
9899

99-
this.queue.start();
100100
this.initialized = true;
101101

102+
// Register session_end listener BEFORE starting the queue so that
103+
// on page unload, session_end is enqueued before the queue flushes.
104+
// DOM event listeners fire in registration order.
105+
this.registerSessionEnd();
106+
this.queue.start();
107+
102108
// Auto-fire page view if consent allows
103109
if (this.consent.level !== 'none') {
104110
this.page();
105111
}
106-
107-
this.registerSessionEnd();
108112
}
109113

110114
page(properties?: Record<string, unknown>): void {
@@ -113,12 +117,14 @@ export class Pixel {
113117
const { sessionId, isNew } = getOrCreateSession(this.domain);
114118
this.refreshSession(sessionId, isNew);
115119
const attribution = collectAttribution();
120+
const thirdPartyIds = this.collectThirdPartyIds();
116121

117122
const message: PageMessage = {
118123
...this.buildBase(),
119124
type: 'page',
120125
properties: {
121126
...attribution,
127+
...thirdPartyIds,
122128
sessionId,
123129
...properties,
124130
},
@@ -230,6 +236,25 @@ export class Pixel {
230236
}
231237
}
232238

239+
// -- Third-party identity signals ----------------------------------------
240+
241+
/**
242+
* Read GA Client ID and Meta Pixel cookies when present.
243+
* These are set by Google Analytics / Meta Pixel scripts and allow
244+
* cross-platform identity stitching without requiring full consent.
245+
*/
246+
// eslint-disable-next-line class-methods-use-this
247+
private collectThirdPartyIds(): Record<string, string> {
248+
const ids: Record<string, string> = {};
249+
const ga = getCookie('_ga');
250+
if (ga) ids.gaClientId = ga;
251+
const fbc = getCookie('_fbc');
252+
if (fbc) ids.fbClickId = fbc;
253+
const fbp = getCookie('_fbp');
254+
if (fbp) ids.fbBrowserId = fbp;
255+
return ids;
256+
}
257+
233258
// -- Helpers ------------------------------------------------------------
234259

235260
// eslint-disable-next-line class-methods-use-this

packages/audience/pixel/tsup.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { defineConfig } from 'tsup';
55
const pkg = JSON.parse(readFileSync(resolve(__dirname, 'package.json'), 'utf-8'));
66

77
export default defineConfig({
8-
entry: ['src/index.ts'],
8+
entry: ['src/iife.ts'],
99
outDir: 'dist',
1010
format: ['iife'],
1111
globalName: '__imtblPixelInternal',

0 commit comments

Comments
 (0)