1/* 2 * LeadTracker AI first-party behaviour tracker â contract v1. 3 * 4 * Embedded by a brand site with one tag: 5 * 6 * <script defer 7 * src="/api/public/attribution/v1/tracker.js" 8 * data-slug="BRAND_PUBLIC_SLUG" 9 * data-post-id="123" 10 * data-post-revision-id="456" 11 * data-cluster="isa-automation" 12 * data-experiment="cta-b"></script> 13 * 14 * It MUST be served from the brand's own origin (the site proxies 15 * /api/public/attribution/* to LeadTracker). The attribution cookie is 16 * HttpOnly + SameSite=Lax by design, so a cross-site request would not carry 17 * it â and third-party cookies are being removed by browsers anyway. 18 * 19 * Markup hooks, all optional: 20 * [data-lt-cta] primary CTA â visibility and clicks 21 * [data-lt-cta-position] mid_article | end | inline | site_header 22 * [data-lt-cta-kind] booking | community (defaults to primary) 23 * [data-lt-form] form container â visibility, start, submit 24 * [data-lt-step] element whose completion is a funnel step 25 * [data-lt-calendar] calendar embed container 26 * 27 * Anything the markup cannot express, the page emits directly: 28 * window.LTTrack.track('booking_success') 29 * window.LTTrack.track('form_validation_error', { error_code: 'email_invalid' }) 30 */ 31(function () { 32 'use strict'; 33 34 var script = document.currentScript; 35 if (!script) return; 36 var slug = script.getAttribute('data-slug'); 37 if (!slug) return; 38 39 // DO NOT measure the hosted application form. 40 // 41 // That funnel is already recorded server-side into public_application_events 42 // and is what the Application Funnel page reports. The hosted form is served 43 // on the brand's own domain through the site proxy, so a site-wide tag would 44 // otherwise load on it and measure the same visitor twice in two systems that 45 // would then disagree â and the server-side record is the authoritative one. 46 // 47 // The form events in this contract are for native forms embedded in content 48 // pages, not for the hosted funnel. 49 if (window.__LT_HOSTED_FUNNEL__) return; 50 if (/^\/(r|form|apply)(\/|$)/.test(location.pathname)) return; 51 52 var base = new URL(script.src, location.href).href.replace(/\/v1\/tracker\.js.*$/, ''); 53 var endpoint = base + '/' + encodeURIComponent(slug); 54 var authoredExperimentId = script.getAttribute('data-experiment') || undefined; 55 var context = { 56 post_id: script.getAttribute('data-post-id') || undefined, 57 post_revision_id: script.getAttribute('data-post-revision-id') || undefined, 58 content_cluster_id: script.getAttribute('data-cluster') || undefined, 59 experiment_id: authoredExperimentId, 60 page_path: location.pathname, 61 }; 62 var currentCtaVariant = null; 63 64 var queue = []; 65 var sessionReady = false; // the attribution cookie has been set by /session 66 var sessionAbort = null; // aborts the in-flight /session once a batch minted the session itself 67 var variantPending = false; // the governed-CTA lookup waits for the cookie; recording does not 68 var sent = {}; // events that must fire at most once per page 69 var flushTimer = null; 70 var started = false; // a form was started 71 var submitted = false; // a form was submitted successfully 72 73 function post(path, body, beacon, onOk) { 74 var url = endpoint + path; 75 var payload = JSON.stringify(body); 76 // sendBeacon survives the page unloading; fetch does not reliably. 77 if (beacon && navigator.sendBeacon) { 78 try { 79 var queued = navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' })); 80 // A beacon's response is unobservable; the page is usually leaving. 81 if (queued && onOk) onOk(false); 82 return queued; 83 } catch (e) { /* fall through to fetch */ } 84 } 85 try { 86 fetch(url, { 87 method: 'POST', 88 headers: { 'content-type': 'application/json' }, 89 body: payload, 90 credentials: 'same-origin', 91 keepalive: !!beacon, 92 }).then(function (response) { 93 if (response && response.ok && onOk) onOk(true); 94 }).catch(function () { /* measurement must never break a page */ }); 95 } catch (e) { /* ignore */ } 96 return true; 97 } 98 99 // First-touch fields, identical to what POST /session receives. Attached to 100 // an events batch that has to leave before the session response has set the 101 // cookie (a reader who taps the CTA within ~1.5s of load â routine on a 102 // phone), so the server can mint the session from the batch itself instead 103 // of refusing it. Before 2026-09-04 that batch was lost, and mobile sessions 104 // showed only a page view. 105 function sessionBootstrap() { 106 var params = new URLSearchParams(location.search); 107 return { 108 landing_path: location.pathname, 109 referrer: document.referrer || '', 110 utm_source: params.get('utm_source') || '', 111 utm_medium: params.get('utm_medium') || '', 112 utm_campaign: params.get('utm_campaign') || '',
113 utm_content: params.get('utm_content') || '', 114 }; 115 } 116 117 function flush(beacon) { 118 if (flushTimer) { clearTimeout(flushTimer); flushTimer = null; } 119 if (!queue.length) return; 120 // The endpoint caps a batch at 20 events. 121 while (queue.length) { 122 var body = { events: queue.splice(0, 20) }; 123 if (sessionReady) { post('/events', body, beacon); continue; } 124 // This batch mints the session itself. Abort the in-flight /session so 125 // one visit does not end up split across two sessions, and treat the 126 // cookie as present from here on: the response sets it before any 127 // later batch (5s timer, unload) can leave. 128 body.session_bootstrap = sessionBootstrap(); 129 if (sessionAbort) { try { sessionAbort.abort(); } catch (e) { /* ignore */ } sessionAbort = null; } 130 sessionReady = true; 131 post('/events', body, beacon, function (observed) { 132 try { sessionStorage.setItem('lt_attr_session_v1', '1'); } catch (e) { /* ignore */ } 133 // The cookie is set by this batch's response. A fetch has told us it 134 // landed; a beacon cannot, so give it a moment â the page is normally 135 // gone by then, and a reader who stayed still gets the governed label. 136 if (observed) maybeRequestVariant(); 137 else setTimeout(maybeRequestVariant, 1500); 138 }); 139 } 140 } 141 142 function track(eventType, props) { 143 if (!eventType) return; 144 var event = { event_type: eventType }; 145 for (var k in context) if (context[k] !== undefined) event[k] = context[k]; 146 if (props) for (var p in props) if (props[p] !== undefined) event[p] = props[p]; 147 queue.push(event); 148 if (queue.length >= 20) flush(false); 149 else if (!flushTimer) flushTimer = setTimeout(function () { flush(false); }, 5000); 150 } 151 152 function once(eventType, props) { 153 if (sent[eventType]) return; 154 sent[eventType] = true; 155 track(eventType, props); 156 } 157 158 function safeVariant(value) { 159 return /^[a-z0-9_]{1,20}$/.test(String(value || '')) ? String(value) : null; 160 } 161 162 // --- session ------------------------------------------------------------ 163 // The cookie is HttpOnly, so the page cannot read it; a per-tab marker keeps 164 // one session request per session instead of one per page view. 165 function ensureSession(done) { 166 var KEY = 'lt_attr_session_v1'; 167 try { if (sessionStorage.getItem(KEY)) { sessionReady = true; return done(); } } catch (e) { /* private mode */ } 168 if (sessionReady) return done(); // a bootstrap batch already minted it 169 sessionAbort = typeof AbortController === 'function' ? new AbortController() : null; 170 fetch(endpoint + '/session', { 171 method: 'POST', 172 headers: { 'content-type': 'application/json' }, 173 credentials: 'same-origin', 174 body: JSON.stringify(sessionBootstrap()), 175 signal: sessionAbort ? sessionAbort.signal : undefined, 176 }).then(function (response) { 177 sessionAbort = null; 178 if (response && response.ok) { 179 sessionReady = true; 180 try { sessionStorage.setItem(KEY, '1'); } catch (e) { /* ignore */ } 181 maybeRequestVariant(); 182 } 183 done(); 184 }).catch(function () { sessionAbort = null; done(); }); 185 } 186 187 // --- engagement --------------------------------------------------------- 188 // Engaged time, not wall-clock time. Reading is passive, so a visible tab 189 // accrues engagement without requiring input â the same basis GA4 uses for an 190 // engaged session. What must not accrue is a tab left open and forgotten, so 191 // accrual stops after a clearly longer idle cutoff than the 30s threshold. 192 // The two constants are deliberately different: if they were equal, a purely 193 // passive reader would qualify exactly at the cutoff by coincidence. 194 var engagedMs = 0; 195 var lastTick = Date.now(); 196 var lastActivity = Date.now(); 197 var ENGAGED_TARGET = 30000; 198 var IDLE_AFTER = 120000; 199 200 ['scroll', 'keydown', 'pointerdown', 'pointermove', 'touchstart'].forEach(function (type) { 201 addEventListener(type, function () { lastActivity = Date.now(); }, { passive: true }); 202 }); 203 204 setInterval(function () { 205 var now = Date.now(); 206 var delta = now - lastTick; 207 lastTick = now; 208 if (document.visibilityState !== 'visible') return; 209 if (now - lastActivity > IDLE_AFTER) return; 210 engagedMs += delta; 211 if (engagedMs >= ENGAGED_TARGET) { 212 once('content_engaged_30s', { metric_value: Math.round(engagedMs / 1000) }); 213 } 214 }, 1000); 215 216 // --- scroll depth ------------------------------------------------------- 217 function scrollDepth() { 218 var doc = document.documentElement; 219 var height = Math.max(doc.scrollHeight, document.body ? document.body.scrollHeight : 0); 220 if (height <= 0) return 0; 221 return Math.min(100, Math.round(((window.scrollY + window.innerHeight) / height) * 100)); 222 } 223 addEventListener('scroll', function () { 224 var depth = scrollDepth(); 225 if (depth >= 50) once('scroll_50', { metric_value: depth }); 226 if (depth >= 90) once('scroll_90', { metric_value: depth }); 227 }, { passive: true }); 228 229 // --- visibility of CTAs, forms and calendars ---------------------------- 230 var observer = null; 231 if (typeof IntersectionObserver === 'function') { 232 observer = new IntersectionObserver(function (entries) {
233 entries.forEach(function (entry) { 234 if (!entry.isIntersecting) return; 235 var el = entry.target; 236 if (el.hasAttribute('data-lt-cta') || implicitCta(el)) { 237 // Visibility is per placement: a CTA seen mid-article and one seen at 238 // the end are different measurements. 239 var position = el.getAttribute('data-lt-cta-position') || undefined; 240 var key = 'primary_cta_view:' + (position || ''); 241 if (!sent[key]) { sent[key] = true; track('primary_cta_view', { cta_position: position }); } 242 } else if (el.hasAttribute('data-lt-form')) { 243 once('form_view'); 244 } else if (el.hasAttribute('data-lt-calendar')) { 245 once('calendar_view'); 246 } 247 observer.unobserve(el); 248 }); 249 }, { threshold: 0.5 }); 250 } 251 252 function observeAll() { 253 if (!observer) return; 254 document.querySelectorAll('[data-lt-cta],[data-lt-form],[data-lt-calendar],a[href]').forEach(function (el) { 255 if (currentCtaVariant && (el.hasAttribute('data-lt-cta') || implicitCta(el))) { 256 applyVariantToCta(el, currentCtaVariant); 257 } 258 if (el.matches && el.matches('a[href]') && !el.hasAttribute('data-lt-cta') && !implicitCta(el)) return; 259 if (!el.__ltObserved) { el.__ltObserved = true; observer.observe(el); } 260 }); 261 } 262 263 // --- clicks, form funnel ------------------------------------------------ 264 // An anchor into the booking/application journey is a CTA whether or not it 265 // was marked up as one. Generated article pages carry an end-of-article CTA 266 // in each file, and there are hundreds of them â recognising the destination 267 // measures those without editing every generated page. 268 var BOOKING_HREF = /^\/(r|form|book|schedule)(\/|$|\?)/; 269 function implicitCta(el) { 270 if (!el || !el.getAttribute) return null; 271 var href = el.getAttribute('href') || ''; 272 if (!href) return null; 273 var path = href; 274 if (/^https?:\/\//i.test(href)) { 275 try { path = new URL(href).pathname; } catch (e) { return null; } 276 } else if (href.charAt(0) !== '/') { 277 return null; 278 } 279 return BOOKING_HREF.test(path) ? el : null; 280 } 281 282 function attributedHref(rawHref, variantKey) { 283 try { 284 var url = new URL(rawHref, location.href); 285 if (!/^https?:$/.test(url.protocol) || url.username || url.password) return rawHref; 286 if (!BOOKING_HREF.test(url.pathname)) return rawHref; 287 url.searchParams.set('utm_content', variantKey); 288 var source = url.searchParams.get('seo_source'); 289 if (source) { 290 var parts = source.split('|'); 291 if (parts.length >= 4) { parts[3] = variantKey; url.searchParams.set('seo_source', parts.join('|')); } 292 } 293 return url.href; 294 } catch (e) { 295 return rawHref; 296 } 297 } 298 299 function ctaLabelTarget(el) { 300 if (!el) return null; 301 var marked = el.querySelector ? el.querySelector('[data-lt-cta-label]') : null; 302 if (marked) return { node: marked, textNode: false }; 303 // Generated CTAs commonly contain a direct text node plus a nested arrow. 304 // Updating only that text node preserves the renderer-owned child and does 305 // not create a child-list mutation. 306 if (el.childNodes) { 307 for (var i = 0; i < el.childNodes.length; i += 1) { 308 var child = el.childNodes[i]; 309 if (child && child.nodeType === 3 && String(child.nodeValue || '').trim()) { 310 return { node: child, textNode: true }; 311 } 312 } 313 // Do not flatten a structured CTA whose label is not explicitly marked. 314 if (el.children && el.children.length) return null; 315 } 316 return { node: el, textNode: false }; 317 } 318 319 function labelTargetValue(target) { 320 if (!target) return ''; 321 return target.textNode ? String(target.node.nodeValue || '') : String(target.node.textContent || ''); 322 } 323 324 function setLabelTargetValue(target, value) { 325 if (!target || labelTargetValue(target).trim() === String(value).trim()) return; 326 if (target.textNode) target.node.nodeValue = value; 327 else target.node.textContent = value; 328 } 329 330 function applyVariantToCta(el, assignment) { 331 if (!el || !el.getAttribute || !el.setAttribute || !assignment) return; 332 if (el.__ltOriginalHref === undefined) el.__ltOriginalHref = el.getAttribute('href') || ''; 333 if (el.__ltLabelTarget === undefined) { 334 el.__ltLabelTarget = ctaLabelTarget(el); 335 el.__ltOriginalLabelText = labelTargetValue(el.__ltLabelTarget); 336 } 337 var destination = assignment.destination || el.__ltOriginalHref; 338 if (destination) { 339 var nextHref = attributedHref(destination, assignment.variant_key); 340 if (el.getAttribute('href') !== nextHref) el.setAttribute('href', nextHref); 341 } 342 if (assignment.label) setLabelTargetValue(el.__ltLabelTarget, assignment.label); 343 if (el.getAttribute('data-lt-experiment-id') !== assignment.experiment_id) { 344 el.setAttribute('data-lt-experiment-id', assignment.experiment_id); 345 } 346 if (el.getAttribute('data-lt-variant') !== assignment.variant_key) { 347 el.setAttribute('data-lt-variant', assignment.variant_key); 348 } 349 } 350 351 function applyCtaVariant(assignment) { 352 var variantKey = safeVariant(assignment && assignment.variant_key); 353 var experimentId = String(assignment && assignment.experiment_id || ''); 354 if (!variantKey || !/^\d+$/.test(experimentId)) return false;
355 currentCtaVariant = { 356 experiment_id: experimentId, 357 variant_key: variantKey, 358 label: typeof assignment.label === 'string' ? assignment.label.slice(0, 160) : '', 359 destination: typeof assignment.destination === 'string' ? assignment.destination.slice(0, 1000) : '', 360 }; 361 context.experiment_id = experimentId; 362 context.cta_variant_key = variantKey; 363 // The server resolves page identity from the signed brand + current path. 364 // Prefer that verified identity over a stale/missing script attribute. 365 if (/^\d+$/.test(String(assignment.post_id || ''))) { 366 context.post_id = String(assignment.post_id); 367 } 368 document.querySelectorAll('[data-lt-cta],a[href]').forEach(function (el) { 369 if (el.hasAttribute('data-lt-cta') || implicitCta(el)) applyVariantToCta(el, currentCtaVariant); 370 }); 371 return true; 372 } 373 374 function clearCtaVariant() { 375 // A persistent site-header CTA can survive an SPA route change. Restore 376 // the page-authored copy/link before asking for the next page's assignment 377 // so a page with no experiment never inherits the previous one. 378 document.querySelectorAll('[data-lt-cta],a[href]').forEach(function (el) { 379 if (el.__ltOriginalHref !== undefined && el.setAttribute && el.getAttribute('href') !== el.__ltOriginalHref) { 380 el.setAttribute('href', el.__ltOriginalHref); 381 } 382 if (el.__ltLabelTarget !== undefined) { 383 setLabelTargetValue(el.__ltLabelTarget, el.__ltOriginalLabelText || ''); 384 } 385 if (el.removeAttribute) { 386 el.removeAttribute('data-lt-experiment-id'); 387 el.removeAttribute('data-lt-variant'); 388 } 389 }); 390 currentCtaVariant = null; 391 if (authoredExperimentId) context.experiment_id = authoredExperimentId; 392 else delete context.experiment_id; 393 delete context.cta_variant_key; 394 } 395 396 function requestCtaVariant(done) { 397 clearCtaVariant(); 398 var requestedPath = location.pathname; 399 var finished = false; 400 function finish() { 401 if (finished) return; 402 finished = true; 403 done(); 404 } 405 // Measurement and page rendering must fail open if an experiment lookup is 406 // slow. A late assignment is ignored for this view so the exposure cannot 407 // be counted under copy that was never shown. 408 var failOpenTimer = setTimeout(finish, 1500); 409 try { 410 fetch(endpoint + '/variant', { 411 method: 'POST', 412 headers: { 'content-type': 'application/json' }, 413 credentials: 'same-origin', 414 body: JSON.stringify({ post_id: context.post_id || '', page_path: requestedPath }), 415 }).then(function (response) { 416 if (!response || !response.ok || response.status === 204 || typeof response.json !== 'function') return null; 417 return response.json(); 418 }).then(function (assignment) { 419 // Do not paint a slow response onto the next SPA route. 420 if (!finished && location.pathname === requestedPath && assignment) applyCtaVariant(assignment); 421 clearTimeout(failOpenTimer); 422 finish(); 423 }).catch(function () { clearTimeout(failOpenTimer); finish(); }); 424 } catch (e) { clearTimeout(failOpenTimer); finish(); } 425 } 426 427 addEventListener('click', function (e) { 428 var target = e.target && e.target.closest ? e.target : null; 429 var cta = target ? target.closest('[data-lt-cta]') : null; 430 if (!cta && target) cta = implicitCta(target.closest('a')); 431 if (!cta) return; 432 var kind = cta.getAttribute('data-lt-cta-kind') || 'booking'; 433 var position = cta.getAttribute('data-lt-cta-position') || undefined; 434 var eventType = kind === 'booking' ? 'booking_cta_click' 435 : kind === 'community' ? 'community_cta_click' 436 : 'primary_cta_click'; 437 track(eventType, { cta_position: position }); 438 // A click usually navigates; do not wait for the idle flush. 439 flush(true); 440 }, true); 441 442 addEventListener('input', function (e) { 443 if (!e.target || !e.target.closest || !e.target.closest('[data-lt-form]')) return; 444 if (!started) { started = true; once('form_start'); } 445 }, true); 446 447 // Native constraint-validation failures are real validation errors and are 448 // otherwise invisible to analytics. 449 addEventListener('invalid', function (e) { 450 var el = e.target; 451 if (!el || !el.closest || !el.closest('[data-lt-form]')) return; 452 track('form_validation_error', { 453 error_code: (el.validity && el.validity.valueMissing ? 'required' : 'invali
453d') + ':' + (el.name || el.id || 'field'), 454 form_step: el.closest('[data-lt-step]') ? el.closest('[data-lt-step]').getAttribute('data-lt-step') : undefined, 455 }); 456 }, true); 457 458 addEventListener('submit', function (e) { 459 var form = e.target && e.target.closest ? e.target.closest('[data-lt-form]') : null; 460 if (!form) return; 461 var step = form.getAttribute('data-lt-step'); 462 if (step) track('form_step_complete', { form_step: step }); 463 // Pages that submit over fetch and decide success themselves opt out and 464 // call LTTrack.track('form_submit_success') when the server confirms. 465 if (!form.hasAttribute('data-lt-manual-success')) { 466 submitted = true; 467 track('form_submit_success'); 468 } 469 flush(true); 470 }, true); 471 472 // --- abandonment -------------------------------------------------------- 473 // Abandonment is only knowable at the end of the visit: a form that was 474 // started and never submitted. 475 function finalize() { 476 if (started && !submitted) once('form_abandoned'); 477 flush(true); 478 } 479 addEventListener('pagehide', finalize); 480 addEventListener('visibilitychange', function () { 481 if (document.visibilityState === 'hidden') flush(true); 482 }); 483 484 // --- public API --------------------------------------------------------- 485 window.LTTrack = { 486 track: function (eventType, props) { 487 if (eventType === 'form_submit_success') submitted = true; 488 track(eventType, props); 489 if (eventType === 'booking_success' || eventType === 'form_submit_success') flush(true); 490 }, 491 flush: function () { flush(false); }, 492 }; 493 494 // --- single-page navigation --------------------------------------------- 495 // The brand sites are SPAs: an article is a client-side route, so without 496 // this only the first page of a visit would ever be measured. A route change 497 // ends the current page (flushing abandonment) and starts a new one. 498 function resetPage() { 499 context.page_path = location.pathname; 500 sent = {}; 501 started = false; 502 submitted = false; 503 engagedMs = 0; 504 lastTick = Date.now(); 505 lastActivity = Date.now(); 506 } 507 508 // The governed-CTA lookup needs the signed cookie (403 without it), so it 509 // runs once the session exists â from /session or from a bootstrap batch. 510 // Until then the page shows its built-in control and is measured as such. 511 function maybeRequestVariant() { 512 if (!variantPending) return; 513 variantPending = false; 514 requestCtaVariant(observeAll); 515 } 516 517 function startAssignedPage() { 518 resetPage(); 519 // Record the view immediately. It used to wait for the variant lookup (up 520 // to 1.5s fail-open), and a reader who left before that never counted. 521 once('page_view'); 522 variantPending = true; 523 if (sessionReady) maybeRequestVariant(); 524 else observeAll(); 525 } 526 527 function onRouteChange() { 528 if (location.pathname === context.page_path) return; // query-only change 529 finalize(); 530 startAssignedPage(); 531 } 532 533 ['pushState', 'replaceState'].forEach(function (method) { 534 var original = history[method]; 535 if (typeof original !== 'function') return; 536 history[method] = function () { 537 var result = original.apply(this, arguments); 538 try { onRouteChange(); } catch (e) { /* never break navigation */ } 539 return result; 540 }; 541 }); 542 addEventListener('popstate', function () { 543 try { onRouteChange(); } catch (e) { /* ignore */ } 544 }); 545 546 // The page starts recording at once; ensureSession only decides whether a 547 // batch needs to carry its own bootstrap. Nothing waits on the network. 548 startAssignedPage(); 549 if (typeof MutationObserver === 'function') { 550 new MutationObserver(observeAll).observe(document.documentElement, { childList: true, subtree: true }); 551 } 552 ensureSession(function () { flush(false); }); 553}());
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.