1/** 2 * Funnel Event Tracking â Unified visitor identity + event pipeline 3 * 4 * Architecture: 5 * 1. Single source of truth for visitor ID (replaces 3 duplicate implementations) 6 * 2. Session ID for grouping events within a visit 7 * 3. Source attribution (UTM, referrer, direct) 8 * 4. sendBeacon delivery for zero INP impact + survives navigation 9 * 5. Fire-and-forget: never blocks UI, never throws 10 * 11 * Events tracked: 12 * - cta_click: User clicks any CTA (blog inline, homepage hero, exit intent) 13 * - file_picker_shown: File picker dialog opened 14 * - file_selected: User chose a file (captures file type + size) 15 * - upload_start: Upload/analysis begins 16 * - upload_complete: Analysis finished (captures score) 17 * - results_viewed: User sees partial results 18 * - signup_cta_click: User clicks "Get Full Analysis" or similar auth CTA 19 * - signup_start: User initiates sign-up/sign-in intent 20 * - signup_complete: User successfully completes authentication 21 * - activation_checkpoint: User reaches post-signup product milestones 22 * - auth_page_view: /auth/login page loaded 23 * - auth_method_click: User clicks a specific auth method 24 * - auth_success: Authentication completed 25 */ 26 27(function () { 28 'use strict'; 29 30 var TEST_MODE_KEY = 'rg_funnel_test'; 31 var TEST_MODE_REASON_KEY = 'rg_funnel_test_reason'; 32 33 // ---- Visitor ID (single source of truth) ---- 34 // Priority: localStorage > cookie > generate 35 // Syncs both directions so all scripts share the same ID 36 // 37 // We also mirror this id into rg_funnel_vid so server-side emits 38 // (e.g. /analyze/upload preauth_created) can attribute events to the 39 // same anonymous visitor instead of falling back to a token prefix. 40 function getOrCreateVisitorId() { 41 var id = localStorage.getItem('ab_vid'); 42 if (id) { 43 mirrorToFunnelCookie(id); 44 return id; 45 } 46 47 var match = document.cookie.match(/ab_vid=([^;]+)/); 48 if (match) { 49 id = match[1]; 50 localStorage.setItem('ab_vid', id); 51 mirrorToFunnelCookie(id); 52 return id; 53 } 54 55 // crypto.randomUUID where available, fallback to manual UUID v4 56 if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { 57 id = crypto.randomUUID(); 58 } else { 59 id = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 60 var r = Math.random() * 16 | 0; 61 var v = c === 'x' ? r : (r & 0x3 | 0x8); 62 return v.toString(16); 63 }); 64 } 65 66 localStorage.setItem('ab_vid', id); 67 mirrorToFunnelCookie(id); 68 return id; 69 } 70 71 function mirrorToFunnelCookie(id) { 72 // 30 days, matches auth.py rg_funnel_vid lifetime so post-auth code 73 // sees a consistent identity from anon visit through signup. 74 var existing = getCookie('rg_funnel_vid'); 75 if (existing === id) return; 76 setCookie('rg_funnel_vid', id, 30 * 24 * 60 * 60); 77 } 78 79 // ---- Session ID (groups events within a single visit) ---- 80 function mirrorSessionCookie(sid) { 81 // Session-scoped cookie (no max-age) so SERVER-emitted funnel 82 // events (preauth_created etc.) can share the browser session 83 // id instead of inventing a phantom one â session-grain joins 84 // were broken because server events used visitor_id as 85 // session_id (2026-06-09). sessionStorage stays the source of 86 // truth; the cookie is a best-effort mirror (multi-tab races 87 // resolve to the most recent tab, which is the one uploading). 88 try { 89 var cookie = 'rg_funnel_sid=' + encodeURIComponent(sid) + '; path=/; SameSite=Lax'; 90 if (window.location.protocol === 'https:') cookie += '; Secure'; 91 document.cookie = cookie; 92 } catch (_e) { /* cookie write best-effort */ } 93 } 94 95 function getOrCreateSessionId() { 96 var sid = sessionStorage.getItem('funnel_sid'); 97 if (sid) { 98 mirrorSessionCookie(sid); 99 return sid; 100 } 101 102 if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { 103 sid = crypto.randomUUID(); 104 } else { 105 sid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { 106 var r = Math.random() * 16 | 0; 107 var v = c === 'x' ? r : (r & 0x3 | 0x8); 108 return v.toString(16); 109 }); 110 } 111 112 sessionStorage.setItem('funnel_sid', sid); 113 mirrorSessionCookie(sid); 114 return sid; 115 } 116 117 function getCookie(name) { 118 var pattern = new RegExp('(?:^|; )' + name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '=([^;]*)'); 119 var match = document.cookie.match(pattern); 120 return match ? decodeURIComponent(match[1]) : ''; 121 } 122 123 function setCookie(name, value, maxAgeSeconds) { 124 var cookie = name + '=' + encodeURIComponent(value) + '; path=/; SameSite=Lax'; 125 if (window.location.protocol === 'https:') cookie += '; Secure'; 126 if (maxAgeSeconds) cookie += '; max-age=' + String(maxAgeSeconds); 127 document.cookie = cookie; 128 } 129 130 function deleteCookie(name) { 131 document.cookie = name + '=; path=/; max-age=0; SameSite=Lax'; 132 } 133 134 function normalizeTestReason(value) { 135 var raw = String(value || '').toLowerCase().trim(); 136 if (!raw) return 'manual'; 137 var sanitized = raw.replace(/[^a-z0-9_-]/g, '_').slice(0, 32); 138 return sanitized || 'manual'; 139 } 140 141 function persistTestMode(enabled, reason) { 142 if (enabled) { 143 var normalizedReason = normalizeTestReason(reason); 144 localStorage.setItem(TEST_MODE_KEY, '1'); 145 localStorage.setItem(TEST_MODE_REASON_KEY, normalizedReason);
146 setCookie(TEST_MODE_KEY, '1', 60 * 60 * 24 * 30); 147 setCookie(TEST_MODE_REASON_KEY, normalizedReason, 60 * 60 * 24 * 30); 148 return; 149 } 150 localStorage.removeItem(TEST_MODE_KEY); 151 localStorage.removeItem(TEST_MODE_REASON_KEY); 152 deleteCookie(TEST_MODE_KEY); 153 deleteCookie(TEST_MODE_REASON_KEY); 154 } 155 156 function processTestModeOverrides() { 157 var params = new URLSearchParams(window.location.search); 158 if (params.has('rg_test')) { 159 var raw = String(params.get('rg_test') || '').toLowerCase(); 160 var enabled = !(raw === '0' || raw === 'false' || raw === 'off'); 161 persistTestMode(enabled, params.get('rg_test_reason') || params.get('test_reason') || 'manual'); 162 } 163 164 var html = document.documentElement; 165 if (html && html.dataset && html.dataset.funnelTest === 'true') { 166 persistTestMode(true, html.dataset.funnelTestReason || 'admin'); 167 } 168 } 169 170 function getTestContext() { 171 var html = document.documentElement; 172 var serverMarked = !!(html && html.dataset && html.dataset.funnelTest === 'true'); 173 var persisted = localStorage.getItem(TEST_MODE_KEY) === '1' || getCookie(TEST_MODE_KEY) === '1'; 174 var isTest = serverMarked || persisted; 175 if (!isTest) { 176 return { is_test: false, test_reason: null }; 177 } 178 var reason = (html && html.dataset && html.dataset.funnelTestReason) || 179 localStorage.getItem(TEST_MODE_REASON_KEY) || 180 getCookie(TEST_MODE_REASON_KEY) || 181 'manual'; 182 return { 183 is_test: true, 184 test_reason: normalizeTestReason(reason) 185 }; 186 } 187 188 function normalizeAiSource(sourceRaw) { 189 if (!sourceRaw) return null; 190 var source = String(sourceRaw).toLowerCase().trim(); 191 if (!source) return null; 192 if (source === 'chatgpt.com' || source === 'chatgpt' || source === 'openai') return 'chatgpt'; 193 if (source.indexOf('perplexity') !== -1 || source.indexOf('pplx') !== -1) return 'perplexity'; 194 if (source.indexOf('gemini') !== -1 || source === 'bard') return 'gemini'; 195 if (source.indexOf('copilot') !== -1 || source.indexOf('microsoft copilot') !== -1) return 'copilot'; 196 if (source.indexOf('claude') !== -1 || source.indexOf('anthropic') !== -1) return 'claude'; 197 if (source.indexOf('deepseek') !== -1) return 'deepseek'; 198 if (source === 'ai') return 'ai'; 199 return null; 200 } 201 202 function parseUrlSafely(rawUrl) { 203 try { 204 return new URL(rawUrl); 205 } catch (e) { 206 return null; 207 } 208 } 209 210 function detectSourceFromReferrer(ref) { 211 var refUrl = parseUrlSafely(ref); 212 if (!refUrl) return 'unknown'; 213 var refHost = refUrl.hostname.toLowerCase(); 214 var refPath = refUrl.pathname.toLowerCase(); 215 216 if (refHost === window.location.hostname) return 'internal'; 217 if (refHost.indexOf('chatgpt.com') !== -1 || refHost.indexOf('chat.openai.com') !== -1 || refHost.indexOf('openai.com') !== -1) return 'chatgpt'; 218 if (refHost.indexOf('perplexity.ai') !== -1 || refHost.indexOf('pplx.ai') !== -1) return 'perplexity'; 219 if (refHost.indexOf('gemini.google.com') !== -1 || refHost.indexOf('bard.google.com') !== -1) return 'gemini'; 220 if (refHost.indexOf('copilot.microsoft.com') !== -1 || refHost.indexOf('edgeservices.bing.com') !== -1) return 'copilot'; 221 if (refHost.indexOf('bing.com') !== -1 && (refPath.indexOf('/chat') !== -1 || refPath.indexOf('/copilot') !== -1)) return 'copilot'; 222 if (refHost.indexOf('claude.ai') !== -1 || refHost.indexOf('anthropic.com') !== -1) return 'claude'; 223 if (refHost.indexOf('deepseek.com') !== -1 || refHost.indexOf('chat.deepseek.com') !== -1) return 'deepseek'; 224 if (refHost.indexOf('google') !== -1) return 'google'; 225 if (refHost.indexOf('bing') !== -1) return 'bing'; 226 if (refHost.indexOf('linkedin') !== -1) return 'linkedin'; 227 if (refHost.indexOf('twitter') !== -1 || refHost.indexOf('x.com') !== -1) return 'twitter'; 228 if (refHost.indexOf('facebook') !== -1) return 'facebook'; 229 return refHost; 230 } 231 232 // ---- Source Attribution ---- 233 function getEntrySource() { 234 var params = new URLSearchParams(window.location.search); 235 236 // Explicit source hints from query params. 237 var aiSource = normalizeAiSource(params.get('ai_source') || params.get('source')); 238 if (aiSource) return aiSource; 239 240 // UTM source takes priority. 241 var utm = params.get('utm_source'); 242 if (utm) { 243 var normalizedUtm = normalizeAiSource(utm); 244 if (normalizedUtm) return normalizedUtm; 245 return utm.toLowerCase(); 246 } 247 248 // UTM medium fallback for AI-tagged links that omit utm_source. 249 var utmMedium = params.get('utm_medium'); 250 if (utmMedium && utmMedium.toLowerCase().indexOf('ai') !== -1) { 251 return 'ai'; 252 } 253 254 // ?from= parameter (used by blog CTA flow) 255 var from = params.get('from'); 256 if (from) { 257 var normalizedFrom = normalizeAiSource(from); 258 if (normalizedFrom) return normalizedFrom; 259 return from; 260 } 261 262 // Referrer-based attribution 263 // PRD-SEC-62: data-server-referrer was removed because it baked one 264 // visitor's Referer into the CDN-cached HTML and served it to 265 // subsequent visitors. document.referrer is per-browser and safe. 266 var ref = document.referrer || ''; 267 if (!ref) return 'direct'; 268 return detectSourceFromReferrer(ref); 269 } 270 271 // ---- Device Detection ---- 272 function getDeviceType() { 273 var isMobile = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) || window.innerWidth < 768; 274 return isMobile ? 'mobile' : 'desktop'; 275 } 276 277 function getCampaignContext() { 278 var params = new URLSearchParams(window.location.search); 279 return { 280 utm_source: params.get('utm_source') || null, 281 utm_medium: params.get('utm_medium') || null, 282 utm_campaign: params.get('utm_campaign') || null,
283 utm_content: params.get('utm_content') || null, 284 utm_term: params.get('utm_term') || null, 285 from: params.get('from') || null, 286 referrer_host: (function () { 287 try { 288 // PRD-SEC-62: see note above â document.referrer only. 289 var r = document.referrer || ''; 290 return r ? new URL(r).hostname : null; 291 } catch (e) { 292 return null; 293 } 294 })(), 295 page_query: window.location.search || null 296 }; 297 } 298 299 function isAttributionNoise(source) { 300 return source === 'internal' || source === 'unknown' || source === 'direct' || !source; 301 } 302 303 function hasExplicitAttributionHint() { 304 var params = new URLSearchParams(window.location.search); 305 if (params.get('ai_source') || params.get('source') || params.get('utm_source') || params.get('from')) return true; 306 var utmMedium = params.get('utm_medium'); 307 return !!(utmMedium && utmMedium.toLowerCase().indexOf('ai') !== -1); 308 } 309 310 function shouldRotateSessionForAttribution(currentSource) { 311 var sessionSource = sessionStorage.getItem('funnel_entry_source'); 312 if (!sessionSource) return false; 313 if (!hasExplicitAttributionHint()) return false; 314 if (isAttributionNoise(currentSource)) return false; 315 return sessionSource !== currentSource; 316 } 317 318 function rotateSessionForAttribution() { 319 sessionStorage.removeItem('funnel_sid'); 320 sessionStorage.removeItem('funnel_session_started'); 321 sessionStorage.removeItem('funnel_campaign_context'); 322 } 323 324 function resolveEntrySource(currentSource) { 325 var sessionSource = sessionStorage.getItem('funnel_entry_source'); 326 var explicitHint = hasExplicitAttributionHint(); 327 if (sessionSource && !(explicitHint && !isAttributionNoise(currentSource))) return sessionSource; 328 329 var firstTouchSource = localStorage.getItem('funnel_first_touch_source'); 330 var resolved = currentSource; 331 332 // Preserve external first-touch source across internal redirects/navigation. 333 if (currentSource === 'internal' && firstTouchSource) { 334 resolved = firstTouchSource; 335 } 336 337 if (!firstTouchSource && !isAttributionNoise(currentSource)) { 338 localStorage.setItem('funnel_first_touch_source', currentSource); 339 localStorage.setItem('funnel_first_touch_at', new Date().toISOString()); 340 firstTouchSource = currentSource; 341 } 342 343 sessionStorage.setItem('funnel_entry_source', resolved); 344 if (!sessionStorage.getItem('funnel_first_touch_source') && firstTouchSource) { 345 sessionStorage.setItem('funnel_first_touch_source', firstTouchSource); 346 } 347 return resolved; 348 } 349 350 function resolveCampaignContext(currentContext, resolvedSource) { 351 var sessionContextRaw = sessionStorage.getItem('funnel_campaign_context'); 352 if (sessionContextRaw) { 353 try { 354 return JSON.parse(sessionContextRaw); 355 } catch (e) { 356 // continue with fresh context 357 } 358 } 359 360 var firstTouchContext = null; 361 var firstTouchRaw = localStorage.getItem('funnel_first_touch_campaign_context'); 362 if (firstTouchRaw) { 363 try { 364 firstTouchContext = JSON.parse(firstTouchRaw); 365 } catch (e) { 366 firstTouchContext = null; 367 } 368 } 369 370 var shouldCaptureFirstTouch = ( 371 currentContext.utm_source || 372 currentContext.utm_campaign || 373 currentContext.from || 374 (!isAttributionNoise(resolvedSource) && resolvedSource !== 'direct') 375 ); 376 if (!firstTouchContext && shouldCaptureFirstTouch) { 377 firstTouchContext = currentContext; 378 localStorage.setItem('funnel_first_touch_campaign_context', JSON.stringify(currentContext)); 379 } 380 381 var merged = Object.assign({}, currentContext); 382 if ((resolvedSource === 'internal' || resolvedSource === 'direct') && firstTouchContext) { 383 merged = Object.assign({}, firstTouchContext, currentContext); 384 } 385 386 merged.first_touch_source = localStorage.getItem('funnel_first_touch_source') || resolvedSource; 387 merged.session_entry_source = resolvedSource; 388 sessionStorage.setItem('funnel_campaign_context', JSON.stringify(merged)); 389 return merged; 390 } 391 392 // ---- Initialize ---- 393 processTestModeOverrides(); 394 var initialSource = getEntrySource(); 395 if (shouldRotateSessionForAttribution(initialSource)) { 396 rotateSessionForAttribution(); 397 } 398 var visitorId = getOrCreateVisitorId(); 399 var sessionId = getOrCreateSessionId(); 400 var entrySource = resolveEntrySource(initialSource); 401 var deviceType = getDeviceType();
402 var campaignContext = resolveCampaignContext(getCampaignContext(), entrySource); 403 404 // ---- Core: Track Funnel Event ---- 405 function trackFunnel(eventName, metadata) { 406 var locale = document.documentElement.dataset.locale || 'en'; 407 // Identity thread (cave-plan-retention-observability, 2026-06-05): 408 // visitor_id is ALWAYS the stable anonymous ab_vid and session_id 409 // is ALWAYS the real browser session. The old behavior swapped 410 // both to the user id at auth, splitting every journey at the auth 411 // boundary and collapsing all authed visits into one eternal 412 // "session". The authed user_id is SERVER-stamped onto rows from 413 // the session cookie at /api/funnel/event (sendBeacon carries 414 // same-origin cookies) â the client neither sends nor can spoof it. 415 var payload = { 416 event_name: eventName, 417 visitor_id: visitorId, 418 session_id: sessionId, 419 device_type: deviceType, 420 page_path: window.location.pathname, 421 entry_source: entrySource, 422 locale: locale 423 }; 424 425 payload.metadata = Object.assign({}, campaignContext, metadata || {}); 426 var testContext = getTestContext(); 427 if (testContext.is_test) { 428 payload.metadata.is_test = true; 429 payload.metadata.test_reason = testContext.test_reason; 430 } 431 432 // sendBeacon: zero INP impact, survives page navigation. 433 // sendBeacon returns false when the browser's beacon queue 434 // rejects the payload â typical during the pre-navigation burst 435 // (upload_complete + builder_draft_created + redirect in one 436 // tick). Fall back to keepalive fetch instead of dropping the 437 // event silently (2026-06-09). 438 var body = JSON.stringify(payload); 439 var queued = false; 440 if (navigator.sendBeacon) { 441 try { 442 queued = navigator.sendBeacon( 443 '/api/funnel/event', 444 new Blob([body], { type: 'application/json' }) 445 ); 446 } catch (_e) { 447 queued = false; 448 } 449 } 450 if (!queued) { 451 fetch('/api/funnel/event', { 452 method: 'POST', 453 headers: { 'Content-Type': 'application/json' }, 454 body: body, 455 keepalive: true 456 }).catch(function () { 457 // Silent failure â analytics never breaks UX 458 }); 459 } 460 461 if (window.location.hostname === 'localhost') { 462 console.log('[Funnel]', eventName, metadata || ''); 463 } 464 } 465 466 // ---- Expose API ---- 467 window.RGTracking = { 468 trackFunnel: trackFunnel, 469 setTestMode: function (enabled, reason) { persistTestMode(!!enabled, reason || 'manual'); }, 470 getTestContext: getTestContext, 471 getVisitorId: function () { return visitorId; }, 472 getSessionId: function () { return sessionId; }, 473 getEntrySource: function () { return entrySource; }, 474 getDeviceType: function () { return deviceType; } 475 }; 476 477 // Track one session-start event per browser tab session. 478 // This gives us baseline AI session volume even when users do not click CTAs. 479 if (!sessionStorage.getItem('funnel_session_started')) { 480 trackFunnel('session_start', { source: 'tracking_bootstrap' }); 481 sessionStorage.setItem('funnel_session_started', '1'); 482 } 483 484 if (window.location.hostname === 'localhost') { 485 console.log('[Funnel] Initialized', { 486 visitorId: visitorId.substring(0, 8) + '...', 487 sessionId: sessionId.substring(0, 8) + '...', 488 entrySource: entrySource, 489 deviceType: deviceType 490 }); 491 } 492 493 // ---- S6: Page Exit with Engagement Metrics ---- 494 // Only fire for authenticated users (server-rendered data attribute) 495 if (document.documentElement.dataset.authenticated === 'true') { 496 var _pageVisibleStart = Date.now(); 497 var _engagedMs = 0; 498 var _maxScrollPct = 0; 499 var _exitFired = false;
500 501 // Accumulate engaged time across visibility changes. 502 // A tab switch pauses the timer; returning resumes it. 503 // The event only fires on pagehide (true navigation away). 504 document.addEventListener('visibilitychange', function () { 505 try { 506 if (document.visibilityState === 'hidden') { 507 _engagedMs += Date.now() - _pageVisibleStart; 508 } else { 509 _pageVisibleStart = Date.now(); 510 } 511 } catch (e) { /* never throw */ } 512 }); 513 514 // Track max scroll depth 515 var _scrollTicking = false; 516 window.addEventListener('scroll', function () { 517 if (_scrollTicking) return; 518 _scrollTicking = true; 519 requestAnimationFrame(function () { 520 try { 521 var scrollTop = window.pageYOffset || document.documentElement.scrollTop; 522 var docHeight = Math.max( 523 document.documentElement.scrollHeight, 524 document.body.scrollHeight 525 ) - window.innerHeight; 526 if (docHeight > 0) { 527 var pct = Math.round((scrollTop / docHeight) * 100); 528 if (pct > _maxScrollPct) _maxScrollPct = pct; 529 } 530 } catch (e) { /* never throw */ } 531 _scrollTicking = false; 532 }); 533 }, { passive: true }); 534 535 // Fire page_exit on actual navigation away (pagehide). 536 // visibilitychange only accumulates time â it does NOT fire the event. 537 // This prevents truncated engaged_ms from tab switches. 538 window.addEventListener('pagehide', function () { 539 try { 540 // Add final visible interval if page was visible when user left 541 if (document.visibilityState !== 'hidden') { 542 _engagedMs += Date.now() - _pageVisibleStart; 543 } 544 _firePageExit(); 545 } catch (e) { /* never throw */ } 546 }); 547 548 function _firePageExit() { 549 if (_exitFired) return; 550 _exitFired = true; 551 552 // Use authenticated user_id if available so page_exit joins 553 // with server-side post-auth events (same identity model). 554 var authUserId = document.documentElement.dataset.userId || ''; 555 var exitVisitorId = authUserId || visitorId; 556 var exitSessionId = authUserId || sessionId; 557 558 var payload = { 559 event_name: 'page_exit', 560 visitor_id: exitVisitorId, 561 session_id: exitSessionId, 562 device_type: deviceType, 563 page_path: window.location.pathname, 564 entry_source: entrySource, 565 locale: document.documentElement.dataset.locale || 'en', 566 metadata: { 567 engaged_ms: _engagedMs, 568 max_scroll_pct: Math.min(_maxScrollPct, 100) 569 } 570 }; 571 572 if (navigator.sendBeacon) { 573 var blob = new Blob([JSON.stringify(payload)], { type: 'application/json' }); 574 navigator.sendBeacon('/api/funnel/event', blob); 575 } 576 } 577 } 578})();
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.