PageSourceSearch

https://swiftleadsai.com/api/public/attribution/v1/tracker.js

js swiftleadsai.com collected 2026-10-04 23:01:34 UTC 23,797 bytes, 553 lines download raw bytes

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.