elysium-attribution 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import { getVisitorId, setVisitorId } from './visitor.js';
2
+ import { extractMarketingParams } from './params.js';
3
+ import { parseReferrer } from './referrer.js';
4
+ import { shouldSendTouch, saveSuccessfulTouch, readTouchHistory } from './session.js';
5
+ import { request } from './transport.js';
6
+ import { getConfig } from '../config/index.js';
7
+ import { logger } from '../utils/logger.js';
8
+
9
+ /**
10
+ * Construct full touch payload with parameters, referrer, and visitor ID
11
+ */
12
+ export function buildPayload(customData = {}) {
13
+ const config = getConfig();
14
+ const visitorId = getVisitorId();
15
+ const { utm, clickIds, cookies, custom } = extractMarketingParams();
16
+ const referrerInfo = parseReferrer();
17
+
18
+ // If UTM source/medium missing, infer from organic/social referrer
19
+ const source = utm.source || referrerInfo.detectedSource || null;
20
+ const medium = utm.medium || referrerInfo.detectedMedium || null;
21
+
22
+ const currentHref = typeof window !== 'undefined' ? window.location.href : '';
23
+ const currentTitle = typeof document !== 'undefined' ? document.title : '';
24
+
25
+ return {
26
+ visitorId,
27
+ touchedAt: new Date().toISOString(),
28
+ source,
29
+ medium,
30
+ campaign: utm.campaign,
31
+ term: utm.term,
32
+ content: utm.content,
33
+ referrer: referrerInfo.referrer,
34
+ landingPage: currentHref,
35
+ pageTitle: currentTitle,
36
+ channel: referrerInfo.channel,
37
+ ...clickIds,
38
+ ...cookies,
39
+ custom: { ...custom, ...customData },
40
+ };
41
+ }
42
+
43
+ /**
44
+ * Record a marketing touchpoint
45
+ */
46
+ export async function recordTouch(options = {}) {
47
+ const config = getConfig();
48
+
49
+ if (!config.apiKey) {
50
+ logger.warn('Tracking disabled: apiKey is not configured.');
51
+ return { ok: false, error: 'apiKey missing' };
52
+ }
53
+
54
+ // Path regex check if configured (e.g. for product pages only)
55
+ if (config.productPathRegex && typeof window !== 'undefined') {
56
+ const regex =
57
+ config.productPathRegex instanceof RegExp
58
+ ? config.productPathRegex
59
+ : new RegExp(config.productPathRegex);
60
+ if (!regex.test(window.location.pathname)) {
61
+ logger.debug('Skipping touch: Path did not match productPathRegex.');
62
+ return { ok: false, skipped: true, reason: 'path_mismatch' };
63
+ }
64
+ }
65
+
66
+ const payload = buildPayload(options.customData);
67
+ const history = readTouchHistory();
68
+
69
+ if (!options.force && !shouldSendTouch(payload, history)) {
70
+ logger.debug('Touch not sent: Duplicate or redundant in active session.');
71
+ return { ok: false, skipped: true, reason: 'deduplicated' };
72
+ }
73
+
74
+ logger.info('Sending touch payload:', payload);
75
+
76
+ const res = await request('/v1/touches', {
77
+ method: 'POST',
78
+ body: payload,
79
+ });
80
+
81
+ if (!res.ok) {
82
+ // If request blocked by adblocker, we still save local event so UI/visitor state remains intact
83
+ if (res.blocked) {
84
+ logger.debug('Touch request blocked by client extension; continuing in local offline mode.');
85
+ } else {
86
+ logger.error('Failed to record touch:', res.error);
87
+ return res;
88
+ }
89
+ }
90
+
91
+ const responseData = res.data || {
92
+ id: `local_${Date.now()}`,
93
+ visitorId: payload.visitorId,
94
+ channel: payload.channel || 'direct',
95
+ touchedAt: payload.touchedAt,
96
+ offlineMode: res.blocked ?? false,
97
+ };
98
+
99
+ saveSuccessfulTouch({
100
+ payload,
101
+ response: responseData,
102
+ recordedAt: Date.now(),
103
+ });
104
+
105
+ logger.info('Touch recorded successfully:', responseData);
106
+
107
+ if (typeof window !== 'undefined') {
108
+ window.dispatchEvent(
109
+ new CustomEvent('elysium:touch_recorded', {
110
+ detail: { payload, response: responseData },
111
+ })
112
+ );
113
+ }
114
+
115
+ return { ok: true, data: responseData };
116
+ }
117
+
118
+ /**
119
+ * Backward-compatible product touch tracker from existing touch-tracking.ts
120
+ */
121
+ export async function recordProductTouch() {
122
+ const config = getConfig();
123
+ const pathRegex = config.productPathRegex || /^\/product\/[^/]+\/?$/;
124
+ if (typeof window !== 'undefined' && !pathRegex.test(window.location.pathname)) {
125
+ return;
126
+ }
127
+ return recordTouch();
128
+ }
129
+
130
+ /**
131
+ * Identify a visitor with their email and optional user traits
132
+ */
133
+ export async function identifyVisitor(email, traits = {}) {
134
+ const config = getConfig();
135
+
136
+ if (!config.apiKey) {
137
+ logger.warn('Visitor identification failed: apiKey is not configured.');
138
+ return false;
139
+ }
140
+
141
+ if (!email || typeof email !== 'string') {
142
+ logger.error('identifyVisitor requires a valid email string.');
143
+ return false;
144
+ }
145
+
146
+ const visitorId = getVisitorId();
147
+ const payload = {
148
+ visitorId,
149
+ email,
150
+ traits,
151
+ identifiedAt: new Date().toISOString(),
152
+ };
153
+
154
+ const res = await request('/v1/identify', {
155
+ method: 'POST',
156
+ body: payload,
157
+ });
158
+
159
+ if (!res.ok) {
160
+ logger.debug('Identify request status:', res.error);
161
+ return false;
162
+ }
163
+
164
+ if (res.data && res.data.canonicalVisitorId) {
165
+ setVisitorId(res.data.canonicalVisitorId);
166
+ }
167
+
168
+ logger.info('Visitor identified successfully:', res.data);
169
+ return true;
170
+ }
171
+
172
+ /**
173
+ * Request checkout attribution session token
174
+ */
175
+ export async function createCheckoutSession() {
176
+ const config = getConfig();
177
+
178
+ if (!config.apiKey) {
179
+ throw new Error('Checkout session was not created: apiKey is not configured.');
180
+ }
181
+
182
+ const visitorId = getVisitorId();
183
+ const res = await request('/v1/checkout-sessions', {
184
+ method: 'POST',
185
+ body: { visitorId },
186
+ });
187
+
188
+ if (!res.ok) {
189
+ // Generate resilient offline session if network/adblocker blocks the API
190
+ return {
191
+ checkoutSessionId: `chk_local_${Date.now()}`,
192
+ token: `tok_local_${Date.now()}`,
193
+ visitorId,
194
+ visitorRecordId: 'local_record',
195
+ touchIds: [],
196
+ touchCount: 1,
197
+ expiresAt: new Date(Date.now() + 3600000).toISOString(),
198
+ };
199
+ }
200
+
201
+ const result = res.data;
202
+ return {
203
+ checkoutSessionId: result.checkoutSessionId,
204
+ token: result.token,
205
+ visitorId: result.visitorId || visitorId,
206
+ visitorRecordId: result.visitorRecordId || '',
207
+ touchIds: Array.isArray(result.touchIds) ? result.touchIds : [],
208
+ touchCount: typeof result.touchCount === 'number' ? result.touchCount : 0,
209
+ expiresAt: result.expiresAt || new Date(Date.now() + 3600000).toISOString(),
210
+ };
211
+ }
@@ -0,0 +1,64 @@
1
+ import { getConfig } from '../config/index.js';
2
+ import { logger } from '../utils/logger.js';
3
+
4
+ export async function request(path, options = {}) {
5
+ const config = getConfig();
6
+ const url = `${config.apiBaseUrl}${path}`;
7
+
8
+ const headers = {
9
+ 'Content-Type': 'application/json',
10
+ ...(config.apiKey ? { 'X-Api-Key': config.apiKey, 'Authorization': `Bearer ${config.apiKey}` } : {}),
11
+ ...options.headers,
12
+ };
13
+
14
+ const timeoutMs = options.timeout ?? 8000;
15
+ let controller = null;
16
+ let timeoutId = null;
17
+
18
+ if (typeof AbortController !== 'undefined') {
19
+ controller = new AbortController();
20
+ timeoutId = setTimeout(() => controller.abort(), timeoutMs);
21
+ }
22
+
23
+ try {
24
+ const response = await fetch(url, {
25
+ method: options.method || 'POST',
26
+ headers,
27
+ body: options.body ? JSON.stringify(options.body) : undefined,
28
+ keepalive: options.keepalive ?? true,
29
+ signal: controller ? controller.signal : undefined,
30
+ });
31
+
32
+ if (timeoutId) clearTimeout(timeoutId);
33
+
34
+ const contentType = response.headers.get('content-type') || '';
35
+ let data = null;
36
+ if (contentType.includes('application/json')) {
37
+ data = await response.json().catch(() => null);
38
+ } else {
39
+ data = await response.text().catch(() => null);
40
+ }
41
+
42
+ if (!response.ok) {
43
+ const errorMsg =
44
+ (data && typeof data === 'object' && (data.message || data.error)) ||
45
+ `Request to ${path} failed with HTTP status ${response.status}`;
46
+ return { ok: false, status: response.status, error: errorMsg, data };
47
+ }
48
+
49
+ return { ok: true, status: response.status, data };
50
+ } catch (error) {
51
+ if (timeoutId) clearTimeout(timeoutId);
52
+ const isAbort = error.name === 'AbortError';
53
+ const isBlocked = error.message?.includes('Failed to fetch') || error.name === 'TypeError';
54
+
55
+ const message = isAbort
56
+ ? `Request to ${path} timed out after ${timeoutMs}ms`
57
+ : isBlocked
58
+ ? `Network request to ${url} was blocked (likely by an ad blocker / privacy extension). Touch gracefully skipped.`
59
+ : `Network error connecting to ${url}: ${error.message}`;
60
+
61
+ logger.debug(message);
62
+ return { ok: false, status: 0, error: message, data: null, blocked: isBlocked };
63
+ }
64
+ }
@@ -0,0 +1,82 @@
1
+ import { safeLocalStorage, getCookie, setCookie } from '../utils/storage.js';
2
+ import { getConfig } from '../config/index.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ export const VISITOR_ID_KEY = 'aid_vid';
6
+ let memoryVisitorId = null;
7
+
8
+ /**
9
+ * Generate standard UUIDv4 with crypto fallback
10
+ */
11
+ export function generateUUID() {
12
+ if (typeof crypto !== 'undefined') {
13
+ if (typeof crypto.randomUUID === 'function') {
14
+ return crypto.randomUUID();
15
+ }
16
+ if (typeof crypto.getRandomValues === 'function') {
17
+ const buffer = new Uint8Array(16);
18
+ crypto.getRandomValues(buffer);
19
+ buffer[6] = (buffer[6] & 0x0f) | 0x40;
20
+ buffer[8] = (buffer[8] & 0x3f) | 0x80;
21
+ const hex = Array.from(buffer, (b) => b.toString(16).padStart(2, '0')).join('');
22
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
23
+ }
24
+ }
25
+
26
+ const randomHex = () => Math.floor(Math.random() * 16).toString(16);
27
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (character) => {
28
+ const value = Number.parseInt(randomHex(), 16);
29
+ return (character === 'x' ? value : (value & 0x3) | 0x8).toString(16);
30
+ });
31
+ }
32
+
33
+ /**
34
+ * Retrieve or create persistent visitor ID.
35
+ * Keeps cookie and localStorage synchronized to prevent identity loss across ITP/subdomains.
36
+ */
37
+ export function getVisitorId() {
38
+ if (memoryVisitorId) {
39
+ return memoryVisitorId;
40
+ }
41
+
42
+ // 1. Try LocalStorage
43
+ let visitorId = safeLocalStorage.getItem(VISITOR_ID_KEY);
44
+
45
+ // 2. Try Cookie if not in LocalStorage
46
+ if (!visitorId) {
47
+ visitorId = getCookie(VISITOR_ID_KEY);
48
+ }
49
+
50
+ // 3. Generate new if missing
51
+ if (!visitorId) {
52
+ visitorId = generateUUID();
53
+ logger.debug('Generated new visitor ID:', visitorId);
54
+ }
55
+
56
+ // 4. Synchronize back to both storages
57
+ const config = getConfig();
58
+ safeLocalStorage.setItem(VISITOR_ID_KEY, visitorId);
59
+ setCookie(VISITOR_ID_KEY, visitorId, {
60
+ maxAge: 63072000, // 2 years
61
+ path: '/',
62
+ domain: config.cookieDomain,
63
+ });
64
+
65
+ memoryVisitorId = visitorId;
66
+ return visitorId;
67
+ }
68
+
69
+ /**
70
+ * Set custom or backend-canonicalized visitor ID
71
+ */
72
+ export function setVisitorId(newId) {
73
+ if (!newId || typeof newId !== 'string') return;
74
+ memoryVisitorId = newId;
75
+ const config = getConfig();
76
+ safeLocalStorage.setItem(VISITOR_ID_KEY, newId);
77
+ setCookie(VISITOR_ID_KEY, newId, {
78
+ maxAge: 63072000,
79
+ path: '/',
80
+ domain: config.cookieDomain,
81
+ });
82
+ }
@@ -0,0 +1,68 @@
1
+ import { getCookie } from '../utils/storage.js';
2
+ import { getConfig } from '../config/index.js';
3
+
4
+ function getParam(params, key) {
5
+ return params.get(key) || null;
6
+ }
7
+
8
+ function getQueryParameter(params, name) {
9
+ return params.get(`utm_${name}`) ?? params.get(name) ?? null;
10
+ }
11
+
12
+ /**
13
+ * Extracts marketing parameters, click IDs, ad cookies, and custom parameters.
14
+ */
15
+ export function extractMarketingParams(locationSearch = '') {
16
+ const search = locationSearch || (typeof window !== 'undefined' ? window.location.search : '');
17
+ const params = new URLSearchParams(search);
18
+ const config = getConfig();
19
+
20
+ // Core UTM parameters
21
+ const utm = {
22
+ source: getQueryParameter(params, 'source'),
23
+ medium: getQueryParameter(params, 'medium'),
24
+ campaign: getQueryParameter(params, 'campaign'),
25
+ term: getQueryParameter(params, 'term'),
26
+ content: getQueryParameter(params, 'content'),
27
+ };
28
+
29
+ // Advertising click IDs
30
+ const clickIds = {
31
+ gclid: getParam(params, 'gclid'),
32
+ dclid: getParam(params, 'dclid'),
33
+ wbraid: getParam(params, 'wbraid'),
34
+ gbraid: getParam(params, 'gbraid'),
35
+ srsltid: getParam(params, 'srsltid'),
36
+ fbclid: getParam(params, 'fbclid'),
37
+ ttclid: getParam(params, 'ttclid'),
38
+ msclkid: getParam(params, 'msclkid'),
39
+ twclid: getParam(params, 'twclid'),
40
+ li_fat_id: getParam(params, 'li_fat_id'),
41
+ rdt_cid: getParam(params, 'rdt_cid'),
42
+ epik: getParam(params, 'epik'),
43
+ };
44
+
45
+ // Ad cookies
46
+ const cookies = {
47
+ fbc: getCookie('_fbc'),
48
+ fbp: getCookie('_fbp'),
49
+ ttp: getCookie('_ttp'),
50
+ ga: getCookie('_ga'),
51
+ };
52
+
53
+ // Custom parameters defined by developer in elysium.config.js
54
+ const custom = {};
55
+ if (Array.isArray(config.customParams)) {
56
+ for (const key of config.customParams) {
57
+ const val = getParam(params, key);
58
+ if (val !== null) custom[key] = val;
59
+ }
60
+ }
61
+
62
+ return {
63
+ utm,
64
+ clickIds,
65
+ cookies,
66
+ custom,
67
+ };
68
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Referrer analysis module: distinguishes internal referrals from external,
3
+ * and classifies organic search and social traffic.
4
+ */
5
+
6
+ const SEARCH_ENGINES = [
7
+ { name: 'google', pattern: /(?:^|\.)google\./i },
8
+ { name: 'bing', pattern: /(?:^|\.)bing\.com/i },
9
+ { name: 'yahoo', pattern: /(?:^|\.)yahoo\.com/i },
10
+ { name: 'duckduckgo', pattern: /(?:^|\.)duckduckgo\.com/i },
11
+ { name: 'baidu', pattern: /(?:^|\.)baidu\.com/i },
12
+ { name: 'yandex', pattern: /(?:^|\.)yandex\./i },
13
+ { name: 'ecosia', pattern: /(?:^|\.)ecosia\.org/i },
14
+ ];
15
+
16
+ const SOCIAL_NETWORKS = [
17
+ { name: 'facebook', pattern: /(?:^|\.)(?:facebook\.com|fb\.com|l\.facebook\.com)/i },
18
+ { name: 'instagram', pattern: /(?:^|\.)instagram\.com/i },
19
+ { name: 'linkedin', pattern: /(?:^|\.)linkedin\.com/i },
20
+ { name: 'twitter', pattern: /(?:^|\.)(?:twitter\.com|t\.co|x\.com)/i },
21
+ { name: 'pinterest', pattern: /(?:^|\.)pinterest\./i },
22
+ { name: 'reddit', pattern: /(?:^|\.)reddit\.com/i },
23
+ { name: 'youtube', pattern: /(?:^|\.)youtube\.com/i },
24
+ { name: 'tiktok', pattern: /(?:^|\.)tiktok\.com/i },
25
+ ];
26
+
27
+ /**
28
+ * Check if the referrer hostname belongs to the same domain
29
+ */
30
+ export function isInternalReferrer(referrerUrl, currentHostname = '') {
31
+ if (!referrerUrl) return false;
32
+ try {
33
+ const refHost = new URL(referrerUrl).hostname.toLowerCase();
34
+ const currHost = (
35
+ currentHostname || (typeof window !== 'undefined' ? window.location.hostname : '')
36
+ ).toLowerCase();
37
+
38
+ if (!currHost || !refHost) return false;
39
+ if (refHost === currHost) return true;
40
+
41
+ // Check parent domain (e.g. shop.mystore.com and mystore.com)
42
+ const refParts = refHost.split('.');
43
+ const currParts = currHost.split('.');
44
+ if (refParts.length >= 2 && currParts.length >= 2) {
45
+ const refRoot = refParts.slice(-2).join('.');
46
+ const currRoot = currParts.slice(-2).join('.');
47
+ if (refRoot === currRoot) return true;
48
+ }
49
+ } catch {
50
+ // Malformed referrer
51
+ }
52
+ return false;
53
+ }
54
+
55
+ /**
56
+ * Parse referrer to identify organic search and social channels
57
+ */
58
+ export function parseReferrer(rawReferrer = '', currentHostname = '') {
59
+ const referrer = rawReferrer || (typeof document !== 'undefined' ? document.referrer : '');
60
+ if (!referrer) {
61
+ return { referrer: null, isInternal: false, channel: 'direct', detectedSource: null, detectedMedium: null };
62
+ }
63
+
64
+ if (isInternalReferrer(referrer, currentHostname)) {
65
+ return { referrer: null, isInternal: true, channel: 'internal', detectedSource: null, detectedMedium: null };
66
+ }
67
+
68
+ try {
69
+ const refUrl = new URL(referrer);
70
+ const host = refUrl.hostname.toLowerCase();
71
+
72
+ for (const se of SEARCH_ENGINES) {
73
+ if (se.pattern.test(host)) {
74
+ return {
75
+ referrer,
76
+ isInternal: false,
77
+ channel: 'organic_search',
78
+ detectedSource: se.name,
79
+ detectedMedium: 'organic',
80
+ };
81
+ }
82
+ }
83
+
84
+ for (const sn of SOCIAL_NETWORKS) {
85
+ if (sn.pattern.test(host)) {
86
+ return {
87
+ referrer,
88
+ isInternal: false,
89
+ channel: 'social',
90
+ detectedSource: sn.name,
91
+ detectedMedium: 'social',
92
+ };
93
+ }
94
+ }
95
+
96
+ return {
97
+ referrer,
98
+ isInternal: false,
99
+ channel: 'referral',
100
+ detectedSource: host,
101
+ detectedMedium: 'referral',
102
+ };
103
+ } catch {
104
+ return { referrer, isInternal: false, channel: 'unknown', detectedSource: null, detectedMedium: null };
105
+ }
106
+ }
@@ -0,0 +1,134 @@
1
+ import { safeLocalStorage } from '../utils/storage.js';
2
+ import { getConfig } from '../config/index.js';
3
+ import { logger } from '../utils/logger.js';
4
+
5
+ export const TOUCH_HISTORY_KEY = 'aid_touches';
6
+ const MAX_HISTORY_LENGTH = 50;
7
+
8
+ /**
9
+ * Validate shape of a stored touch record
10
+ */
11
+ export function isStoredTouch(value) {
12
+ if (typeof value !== 'object' || value === null) return false;
13
+ return (
14
+ typeof value.recordedAt === 'number' &&
15
+ typeof value.payload === 'object' &&
16
+ value.payload !== null &&
17
+ typeof value.response === 'object' &&
18
+ value.response !== null
19
+ );
20
+ }
21
+
22
+ /**
23
+ * Read and validate touch history from storage
24
+ */
25
+ export function readTouchHistory() {
26
+ try {
27
+ const raw = safeLocalStorage.getItem(TOUCH_HISTORY_KEY);
28
+ if (!raw) return [];
29
+
30
+ const parsed = JSON.parse(raw);
31
+ if (!Array.isArray(parsed)) return [];
32
+
33
+ return parsed.filter(isStoredTouch);
34
+ } catch (error) {
35
+ logger.debug('Could not read touch history:', error);
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Save accepted touch with FIFO truncation to prevent storage exhaustion
42
+ */
43
+ export function saveSuccessfulTouch(touch) {
44
+ try {
45
+ const history = readTouchHistory();
46
+ history.push(touch);
47
+
48
+ // Keep history bounded to avoid QuotaExceededError
49
+ while (history.length > MAX_HISTORY_LENGTH) {
50
+ history.shift();
51
+ }
52
+
53
+ safeLocalStorage.setItem(TOUCH_HISTORY_KEY, JSON.stringify(history));
54
+ } catch (error) {
55
+ logger.error('Failed to save touch to storage:', error);
56
+ }
57
+ }
58
+
59
+ /**
60
+ * Check if the payload contains an active marketing signal (paid or explicit campaign)
61
+ */
62
+ export function hasMarketingSignal(payload) {
63
+ if (!payload) return false;
64
+ return Boolean(
65
+ payload.source ||
66
+ payload.medium ||
67
+ payload.campaign ||
68
+ payload.gclid ||
69
+ payload.fbclid ||
70
+ payload.ttclid ||
71
+ payload.msclkid ||
72
+ payload.dclid ||
73
+ payload.wbraid ||
74
+ payload.gbraid ||
75
+ payload.twclid ||
76
+ payload.li_fat_id ||
77
+ payload.rdt_cid
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Generate a signature for deduplication
83
+ */
84
+ export function getTouchSignature(payload) {
85
+ return [
86
+ payload.visitorId || '',
87
+ payload.source || '',
88
+ payload.medium || '',
89
+ payload.campaign || '',
90
+ payload.gclid || '',
91
+ payload.fbclid || '',
92
+ payload.ttclid || '',
93
+ payload.msclkid || '',
94
+ payload.dclid || '',
95
+ payload.wbraid || '',
96
+ payload.gbraid || '',
97
+ payload.landingPage || '',
98
+ ].join('|');
99
+ }
100
+
101
+ /**
102
+ * Determine whether a new touch should be transmitted to the backend.
103
+ * Prevents duplicates while ensuring multi-touch and session restarts are respected.
104
+ */
105
+ export function shouldSendTouch(payload, history = null) {
106
+ const touchHistory = history || readTouchHistory();
107
+ const config = getConfig();
108
+ const sessionWindow = config.sessionWindowMs || 30 * 60 * 1000;
109
+
110
+ const previous = touchHistory[touchHistory.length - 1];
111
+
112
+ // If no previous touch recorded, always send
113
+ if (!previous) return true;
114
+
115
+ // If previous touch is older than the session window, this is a new session
116
+ if (Date.now() - previous.recordedAt > sessionWindow) return true;
117
+
118
+ const previousWasAttributed = hasMarketingSignal(previous.payload);
119
+ const currentIsAttributed = hasMarketingSignal(payload);
120
+
121
+ // Guard against overwriting an active paid ad attribution with an internal/direct navigation
122
+ if (previousWasAttributed && !currentIsAttributed) {
123
+ logger.debug('Skipping touch: Recent paid attribution protected from direct browse navigation.');
124
+ return false;
125
+ }
126
+
127
+ // Exact duplicate signature within the same session window
128
+ if (getTouchSignature(previous.payload) === getTouchSignature(payload)) {
129
+ logger.debug('Skipping touch: Duplicate touch signature within active session window.');
130
+ return false;
131
+ }
132
+
133
+ return true;
134
+ }