diff --git a/packages/audience/pixel/src/autocapture.test.ts b/packages/audience/pixel/src/autocapture.test.ts new file mode 100644 index 0000000000..ead65b4528 --- /dev/null +++ b/packages/audience/pixel/src/autocapture.test.ts @@ -0,0 +1,574 @@ +import { TextEncoder as NodeTextEncoder } from 'util'; +import { createHash } from 'crypto'; +import type { ConsentLevel } from '@imtbl/audience-core'; +import { setupAutocapture } from './autocapture'; + +// Polyfill TextEncoder for older jsdom +if (typeof globalThis.TextEncoder === 'undefined') { + (globalThis as Record).TextEncoder = NodeTextEncoder; +} + +// Polyfill crypto.subtle.digest for jsdom +const originalCrypto = global.crypto; +beforeAll(() => { + Object.defineProperty(global, 'crypto', { + value: { + ...originalCrypto, + subtle: { + async digest(_algo: string, data: Uint8Array) { + const hash = createHash('sha256').update(data).digest(); + return hash.buffer; + }, + }, + }, + configurable: true, + }); +}); + +afterAll(() => { + Object.defineProperty(global, 'crypto', { + value: originalCrypto, + configurable: true, + }); +}); + +describe('autocapture', () => { + let enqueue: jest.Mock; + let consent: ConsentLevel; + let teardown: () => void; + + beforeEach(() => { + enqueue = jest.fn(); + consent = 'anonymous'; + document.body.innerHTML = ''; + }); + + afterEach(() => { + teardown?.(); + }); + + function setup(options = {}) { + teardown = setupAutocapture( + { forms: true, clicks: true, ...options }, + enqueue, + () => consent, + ); + } + + // ---------- Form submissions ---------- + + describe('form submissions', () => { + it('fires form_submitted at anonymous consent (no email hash)', () => { + setup(); + const form = document.createElement('form'); + form.action = '/signup'; + form.id = 'signup-form'; + form.setAttribute('name', 'newsletter'); + + const emailInput = document.createElement('input'); + emailInput.type = 'email'; + emailInput.name = 'email'; + emailInput.value = 'test@example.com'; + form.appendChild(emailInput); + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledWith( + 'form_submitted', + expect.objectContaining({ + formAction: expect.stringContaining('/signup'), + formId: 'signup-form', + formName: 'newsletter', + fieldNames: ['email'], + }), + ); + + // At anonymous consent, no emailHash + const props = enqueue.mock.calls[0][1]; + expect(props.emailHash).toBeUndefined(); + }); + + it('fires form_submitted with email hash at full consent', async () => { + consent = 'full'; + setup(); + + const form = document.createElement('form'); + form.action = '/register'; + + const emailInput = document.createElement('input'); + emailInput.type = 'email'; + emailInput.name = 'email'; + emailInput.value = 'Player@Example.com'; + form.appendChild(emailInput); + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + + // Hash is async, wait for microtask + await new Promise((r) => { setTimeout(r, 0); }); + + expect(enqueue).toHaveBeenCalledWith( + 'form_submitted', + expect.objectContaining({ + formAction: expect.stringContaining('/register'), + emailHash: expect.stringMatching(/^sha256:[a-f0-9]{64}$/), + }), + ); + }); + + it('normalises email before hashing (lowercase + trim)', async () => { + consent = 'full'; + setup(); + + const form = document.createElement('form'); + const emailInput = document.createElement('input'); + emailInput.type = 'email'; + emailInput.name = 'email'; + emailInput.value = ' Test@Example.COM '; + form.appendChild(emailInput); + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + await new Promise((r) => { setTimeout(r, 0); }); + + // Verify the hash corresponds to the normalised value + const expected = createHash('sha256') + .update(new TextEncoder().encode('test@example.com')) + .digest('hex'); + + expect(enqueue.mock.calls[0][1].emailHash).toBe(`sha256:${expected}`); + }); + + it('detects email inputs by name containing "email"', () => { + setup(); + const form = document.createElement('form'); + + const input = document.createElement('input'); + input.type = 'text'; + input.name = 'user_email_address'; + input.value = 'test@example.com'; + form.appendChild(input); + document.body.appendChild(form); + + consent = 'full'; + form.dispatchEvent(new Event('submit', { bubbles: true })); + + // Should have attempted to hash (enqueue called async) + // At anonymous it would enqueue synchronously with no hash + consent = 'anonymous'; + enqueue.mockClear(); + form.dispatchEvent(new Event('submit', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledWith( + 'form_submitted', + expect.objectContaining({ + fieldNames: ['user_email_address'], + }), + ); + }); + + it('does not fire at consent none', () => { + consent = 'none'; + setup(); + + const form = document.createElement('form'); + form.action = '/signup'; + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('captures fieldNames from all named form elements', () => { + setup(); + const form = document.createElement('form'); + + const nameInput = document.createElement('input'); + nameInput.name = 'first_name'; + form.appendChild(nameInput); + + const emailInput = document.createElement('input'); + emailInput.name = 'email'; + form.appendChild(emailInput); + + const selectEl = document.createElement('select'); + selectEl.name = 'country'; + form.appendChild(selectEl); + + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + + expect(enqueue.mock.calls[0][1].fieldNames).toEqual([ + 'first_name', + 'email', + 'country', + ]); + }); + + it('does not capture when forms option is false', () => { + setup({ forms: false }); + + const form = document.createElement('form'); + form.action = '/signup'; + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('fires for forms without email inputs (no hash attempt)', () => { + consent = 'full'; + setup(); + + const form = document.createElement('form'); + form.action = '/search'; + const input = document.createElement('input'); + input.name = 'query'; + input.value = 'game name'; + form.appendChild(input); + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledWith( + 'form_submitted', + expect.objectContaining({ + formAction: expect.stringContaining('/search'), + fieldNames: ['query'], + }), + ); + expect(enqueue.mock.calls[0][1].emailHash).toBeUndefined(); + }); + + it('enqueues without emailHash when crypto.subtle is unavailable', async () => { + consent = 'full'; + + // Temporarily break crypto.subtle + const saved = global.crypto; + Object.defineProperty(global, 'crypto', { + value: { subtle: undefined }, + configurable: true, + }); + + setup(); + + const form = document.createElement('form'); + form.action = '/signup'; + const input = document.createElement('input'); + input.type = 'email'; + input.name = 'email'; + input.value = 'test@example.com'; + form.appendChild(input); + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + await new Promise((r) => { setTimeout(r, 0); }); + + // Event should still be enqueued, just without emailHash + expect(enqueue).toHaveBeenCalledWith( + 'form_submitted', + expect.objectContaining({ formAction: expect.stringContaining('/signup') }), + ); + expect(enqueue.mock.calls[0][1].emailHash).toBeUndefined(); + + // Restore crypto + Object.defineProperty(global, 'crypto', { value: saved, configurable: true }); + }); + + it('ignores email inputs without @ sign', () => { + consent = 'full'; + setup(); + + const form = document.createElement('form'); + const input = document.createElement('input'); + input.type = 'email'; + input.name = 'email'; + input.value = 'not-an-email'; + form.appendChild(input); + document.body.appendChild(form); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + + // Should fire synchronously (no hash), no emailHash + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0][1].emailHash).toBeUndefined(); + }); + }); + + // ---------- Outbound link clicks ---------- + + describe('outbound link clicks', () => { + it('fires link_clicked for outbound links', () => { + setup(); + + const link = document.createElement('a'); + link.href = 'https://store.steampowered.com/app/12345'; + link.textContent = 'Wishlist on Steam'; + link.id = 'steam-link'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledWith( + 'link_clicked', + { + linkUrl: 'https://store.steampowered.com/app/12345', + linkText: 'Wishlist on Steam', + elementId: 'steam-link', + outbound: true, + }, + ); + }); + + it('does not fire for internal links', () => { + setup(); + + const link = document.createElement('a'); + link.href = `${window.location.origin}/about`; + link.textContent = 'About'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('does not fire at consent none', () => { + consent = 'none'; + setup(); + + const link = document.createElement('a'); + link.href = 'https://discord.gg/invite'; + link.textContent = 'Join Discord'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('fires at anonymous consent', () => { + consent = 'anonymous'; + setup(); + + const link = document.createElement('a'); + link.href = 'https://discord.gg/invite'; + link.textContent = 'Join Discord'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).toHaveBeenCalledWith( + 'link_clicked', + expect.objectContaining({ + linkUrl: 'https://discord.gg/invite', + outbound: true, + }), + ); + }); + + it('fires at full consent', () => { + consent = 'full'; + setup(); + + const link = document.createElement('a'); + link.href = 'https://discord.gg/invite'; + link.textContent = 'Join Discord'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).toHaveBeenCalledTimes(1); + }); + + it('truncates link text to 256 characters', () => { + setup(); + + const link = document.createElement('a'); + link.href = 'https://example.com/external'; + link.textContent = 'A'.repeat(300); + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + + const props = enqueue.mock.calls[0][1]; + expect(props.linkText).toHaveLength(256); + }); + + it('resolves clicks on child elements to nearest anchor', () => { + setup(); + + const link = document.createElement('a'); + link.href = 'https://store.steampowered.com/app/12345'; + link.textContent = ''; + + const span = document.createElement('span'); + span.textContent = 'Click me'; + link.appendChild(span); + document.body.appendChild(link); + + span.dispatchEvent(new Event('click', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledWith( + 'link_clicked', + expect.objectContaining({ + linkUrl: 'https://store.steampowered.com/app/12345', + linkText: 'Click me', + outbound: true, + }), + ); + }); + + it('does not fire when clicks option is false', () => { + setup({ clicks: false }); + + const link = document.createElement('a'); + link.href = 'https://store.steampowered.com/app/12345'; + link.textContent = 'Steam'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('omits elementId when anchor has no id', () => { + setup(); + + const link = document.createElement('a'); + link.href = 'https://discord.gg/invite'; + link.textContent = 'Discord'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + + expect(enqueue.mock.calls[0][1].elementId).toBeUndefined(); + }); + + it('ignores clicks on non-link elements', () => { + setup(); + + const button = document.createElement('button'); + button.textContent = 'Click me'; + document.body.appendChild(button); + + button.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + + it('ignores anchors without href', () => { + setup(); + + const link = document.createElement('a'); + link.textContent = 'No href'; + document.body.appendChild(link); + + link.dispatchEvent(new Event('click', { bubbles: true })); + expect(enqueue).not.toHaveBeenCalled(); + }); + }); + + // ---------- Teardown ---------- + + describe('teardown', () => { + it('removes all listeners when teardown is called', () => { + setup(); + + const form = document.createElement('form'); + form.action = '/signup'; + document.body.appendChild(form); + + const link = document.createElement('a'); + link.href = 'https://discord.gg/invite'; + link.textContent = 'Discord'; + document.body.appendChild(link); + + teardown(); + + form.dispatchEvent(new Event('submit', { bubbles: true })); + link.dispatchEvent(new Event('click', { bubbles: true })); + + expect(enqueue).not.toHaveBeenCalled(); + }); + }); + + // ---------- Config defaults ---------- + + describe('config defaults', () => { + it('enables both listeners when no options specified', () => { + teardown = setupAutocapture({}, enqueue, () => consent); + + const form = document.createElement('form'); + form.action = '/signup'; + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + + const link = document.createElement('a'); + link.href = 'https://external.com'; + link.textContent = 'External'; + document.body.appendChild(link); + link.dispatchEvent(new Event('click', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledTimes(2); + }); + + it('can disable forms but keep clicks', () => { + setup({ forms: false, clicks: true }); + + const form = document.createElement('form'); + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + + const link = document.createElement('a'); + link.href = 'https://external.com'; + link.textContent = 'Ext'; + document.body.appendChild(link); + link.dispatchEvent(new Event('click', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0][0]).toBe('link_clicked'); + }); + + it('can disable clicks but keep forms', () => { + setup({ forms: true, clicks: false }); + + const link = document.createElement('a'); + link.href = 'https://external.com'; + link.textContent = 'Ext'; + document.body.appendChild(link); + link.dispatchEvent(new Event('click', { bubbles: true })); + + const form = document.createElement('form'); + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + + expect(enqueue).toHaveBeenCalledTimes(1); + expect(enqueue.mock.calls[0][0]).toBe('form_submitted'); + }); + }); + + // ---------- Email hashing ---------- + + describe('email hashing', () => { + it('produces consistent SHA-256 output', async () => { + consent = 'full'; + setup(); + + // Submit the same email twice + for (let i = 0; i < 2; i++) { + const form = document.createElement('form'); + const input = document.createElement('input'); + input.type = 'email'; + input.name = 'email'; + input.value = 'consistent@test.com'; + form.appendChild(input); + document.body.appendChild(form); + form.dispatchEvent(new Event('submit', { bubbles: true })); + } + + await new Promise((r) => { setTimeout(r, 0); }); + + expect(enqueue).toHaveBeenCalledTimes(2); + const hash1 = enqueue.mock.calls[0][1].emailHash; + const hash2 = enqueue.mock.calls[1][1].emailHash; + expect(hash1).toBe(hash2); + expect(hash1).toMatch(/^sha256:[a-f0-9]{64}$/); + }); + }); +}); diff --git a/packages/audience/pixel/src/autocapture.ts b/packages/audience/pixel/src/autocapture.ts new file mode 100644 index 0000000000..998cfeb878 --- /dev/null +++ b/packages/audience/pixel/src/autocapture.ts @@ -0,0 +1,138 @@ +import type { ConsentLevel } from '@imtbl/audience-core'; + +export interface AutocaptureOptions { + /** Enable form submission auto-capture. Default: true */ + forms?: boolean; + /** Enable outbound link click auto-capture. Default: true */ + clicks?: boolean; +} + +type EnqueueFn = (eventName: string, properties: Record) => void; +type ConsentFn = () => ConsentLevel; + +/** + * SHA-256 hash a value using Web Crypto API. + * Returns `sha256:`. The raw value never enters the event queue. + */ +async function hashSHA256(input: string): Promise { + const data = new TextEncoder().encode(input.toLowerCase().trim()); + const buf = await crypto.subtle.digest('SHA-256', data); + const hex = Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); + return `sha256:${hex}`; +} + +function isEmailInput(el: HTMLInputElement): boolean { + if (el.type === 'email') return true; + const name = (el.name || el.id || '').toLowerCase(); + return name.includes('email'); +} + +function findEmail(form: HTMLFormElement): string | null { + const inputs = form.querySelectorAll('input'); + for (let i = 0; i < inputs.length; i++) { + const input = inputs[i]; + if (isEmailInput(input) && input.value && input.value.includes('@')) { + return input.value; + } + } + return null; +} + +function getFieldNames(form: HTMLFormElement): string[] { + const names: string[] = []; + const { elements } = form; + for (let i = 0; i < elements.length; i++) { + const name = (elements[i] as HTMLInputElement).name + || (elements[i] as HTMLInputElement).id; + if (name && !names.includes(name)) names.push(name); + } + return names; +} + +/** + * Attach document-level listeners for form submissions and outbound link clicks. + * Returns a teardown function that removes all listeners. + * + * - Single document-level listener per event type (event delegation). + * - Consent is checked at fire time, not at attach time. + * - Email values are SHA-256 hashed client-side before enqueuing. + */ +export function setupAutocapture( + options: AutocaptureOptions, + enqueue: EnqueueFn, + getConsent: ConsentFn, +): () => void { + const teardowns: Array<() => void> = []; + + if (options.forms !== false) { + const onSubmit = (e: Event): void => { + if (getConsent() === 'none') return; + + const form = e.target as HTMLFormElement; + if (!form || form.tagName !== 'FORM') return; + + const properties: Record = { + formAction: form.action || undefined, + formId: form.id || undefined, + formName: form.getAttribute('name') || undefined, + fieldNames: getFieldNames(form), + }; + + const consent = getConsent(); + if (consent === 'full') { + const email = findEmail(form); + if (email) { + // Hash before enqueuing — raw email never enters the queue. + // If crypto.subtle is unavailable (HTTP pages, older browsers), + // enqueue without emailHash rather than losing the event entirely. + hashSHA256(email).then( + (hash) => { + properties.emailHash = hash; + enqueue('form_submitted', properties); + }, + () => { + enqueue('form_submitted', properties); + }, + ); + return; + } + } + + enqueue('form_submitted', properties); + }; + + document.addEventListener('submit', onSubmit, true); + teardowns.push(() => document.removeEventListener('submit', onSubmit, true)); + } + + if (options.clicks !== false) { + const onClick = (e: Event): void => { + if (getConsent() === 'none') return; + + const target = e.target as HTMLElement; + const anchor = target.closest?.('a') as HTMLAnchorElement | null; + if (!anchor || !anchor.href) return; + + try { + const linkHost = new URL(anchor.href, window.location.origin).hostname; + if (linkHost === window.location.hostname) return; + + enqueue('link_clicked', { + linkUrl: anchor.href, + linkText: (anchor.textContent || '').trim().slice(0, 256), + elementId: anchor.id || undefined, + outbound: true, + }); + } catch { + // Invalid URL — skip silently + } + }; + + document.addEventListener('click', onClick, true); + teardowns.push(() => document.removeEventListener('click', onClick, true)); + } + + return () => teardowns.forEach((fn) => fn()); +} diff --git a/packages/audience/pixel/src/index.ts b/packages/audience/pixel/src/index.ts index 9deb78e510..bcadbfec78 100644 --- a/packages/audience/pixel/src/index.ts +++ b/packages/audience/pixel/src/index.ts @@ -6,3 +6,5 @@ export type { Command, ImtblGlobal } from './loader'; export { generateSnippet } from './snippet'; export type { SnippetOptions } from './snippet'; + +export type { AutocaptureOptions } from './autocapture'; diff --git a/packages/audience/pixel/src/pixel.test.ts b/packages/audience/pixel/src/pixel.test.ts index a37a0079f1..4bb9785357 100644 --- a/packages/audience/pixel/src/pixel.test.ts +++ b/packages/audience/pixel/src/pixel.test.ts @@ -1,5 +1,12 @@ import { Pixel } from './pixel'; +// Mock autocapture module +const mockTeardownAutocapture = jest.fn(); +const mockSetupAutocapture = jest.fn().mockReturnValue(mockTeardownAutocapture); +jest.mock('./autocapture', () => ({ + setupAutocapture: (...args: unknown[]) => mockSetupAutocapture(...args), +})); + // Mock audience-core const mockEnqueue = jest.fn(); const mockStart = jest.fn(); @@ -329,5 +336,85 @@ describe('Pixel', () => { pixel.page(); expect(mockEnqueue).not.toHaveBeenCalled(); }); + + it('tears down autocapture listeners', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + pixel.destroy(); + expect(mockTeardownAutocapture).toHaveBeenCalled(); + }); + }); + + describe('autocapture integration', () => { + it('sets up autocapture with default options on init', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + expect(mockSetupAutocapture).toHaveBeenCalledWith( + { forms: undefined, clicks: undefined }, + expect.any(Function), + expect.any(Function), + ); + }); + + it('passes autocapture options to setupAutocapture', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ + key: 'pk_test', + environment: 'dev', + consent: 'anonymous', + autocapture: { forms: false, clicks: true }, + }); + + expect(mockSetupAutocapture).toHaveBeenCalledWith( + { forms: false, clicks: true }, + expect.any(Function), + expect.any(Function), + ); + }); + + it('enqueue callback fires TrackMessage with session', () => { + mockGetOrCreateSession.mockReturnValue({ sessionId: 'session-xyz', isNew: false }); + + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + mockEnqueue.mockClear(); + + // Call the enqueue callback that was passed to setupAutocapture + const enqueueCallback = mockSetupAutocapture.mock.calls[0][1] as ( + eventName: string, + properties: Record, + ) => void; + enqueueCallback('form_submitted', { formAction: '/signup' }); + + expect(mockEnqueue).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'track', + eventName: 'form_submitted', + surface: 'pixel', + properties: expect.objectContaining({ + formAction: '/signup', + sessionId: 'session-xyz', + }), + }), + ); + }); + + it('consent callback returns current consent level', () => { + const pixel = new Pixel(); + activePixel = pixel; + pixel.init({ key: 'pk_test', environment: 'dev', consent: 'anonymous' }); + + const getConsent = mockSetupAutocapture.mock.calls[0][2] as () => string; + expect(getConsent()).toBe('anonymous'); + + pixel.setConsent('full'); + expect(getConsent()).toBe('full'); + }); }); }); diff --git a/packages/audience/pixel/src/pixel.ts b/packages/audience/pixel/src/pixel.ts index c68458d644..99de797555 100644 --- a/packages/audience/pixel/src/pixel.ts +++ b/packages/audience/pixel/src/pixel.ts @@ -24,6 +24,8 @@ import { getOrCreateSession, createConsentManager, } from '@imtbl/audience-core'; +import { setupAutocapture } from './autocapture'; +import type { AutocaptureOptions } from './autocapture'; // Replaced at build time by tsup `define` (see tsup.config.ts). // In tests the global isn't defined, so we fall back to 'unknown'. @@ -36,6 +38,7 @@ export interface PixelInitOptions { environment?: Environment; consent?: ConsentLevel; domain?: string; + autocapture?: AutocaptureOptions; } export class Pixel { @@ -61,6 +64,8 @@ export class Pixel { private unloadHandler?: () => void; + private teardownAutocapture?: () => void; + init(options: PixelInitOptions): void { if (this.initialized) return; @@ -69,6 +74,7 @@ export class Pixel { environment = 'production', consent: consentLevel, domain, + autocapture, } = options; this.publishableKey = key; @@ -109,6 +115,13 @@ export class Pixel { if (this.consent.level !== 'none') { this.page(); } + + // Attach autocapture listeners (forms + outbound clicks) + this.teardownAutocapture = setupAutocapture( + { forms: autocapture?.forms, clicks: autocapture?.clicks }, + (eventName, properties) => this.track(eventName, properties), + () => this.consent!.level, + ); } page(properties?: Record): void { @@ -161,6 +174,10 @@ export class Pixel { destroy(): void { this.removeSessionEnd(); + if (this.teardownAutocapture) { + this.teardownAutocapture(); + this.teardownAutocapture = undefined; + } if (this.queue) { this.queue.destroy(); this.queue = null; @@ -169,6 +186,25 @@ export class Pixel { this.initialized = false; } + // -- Auto-capture helper -------------------------------------------------- + + private track(eventName: string, properties: Record): void { + if (!this.canTrack()) return; + + const { sessionId, isNew } = getOrCreateSession(this.domain); + this.refreshSession(sessionId, isNew); + + const message: TrackMessage = { + ...this.buildBase(), + type: 'track', + eventName, + properties: { ...properties, sessionId }, + userId: this.consent!.level === 'full' ? this.userId : undefined, + }; + + this.queue!.enqueue(message); + } + // -- Session lifecycle -------------------------------------------------- private refreshSession(sessionId: string, isNew: boolean): void {