1/** 2 * Core Web Vitals (RUM) collector for connecteam.com. 3 * 4 * Captures the real-user Core Web Vitals â LCP, INP, CLS, and TTFB â using the 5 * official Google `web-vitals` library (loaded first as `window.webVitals`) and 6 * beacons them to the existing BIEngine performance-ingestion endpoint 7 * `POST https://bi.connecteam.com/bi/api/Performance/Web/`. 8 * 9 * The payload conforms to the backend's ReportHitsBody contract: 10 * { metadata: { sessionId, clientId, visitId? }, events: [ { type, hit } ] } 11 * where sessionId/clientId are the cta first-party UUIDs (reused from window.cta), 12 * `type` is "eventHit", and each `hit` carries { label, value, category, ... }. 13 * 14 * Vitals are only final at page hide, so we buffer readings and flush once via 15 * navigator.sendBeacon on the first `visibilitychange -> hidden` and on `pagehide` 16 * (fire-and-forget). Downstream, BIEngine publishes to the `performance_event_hit` 17 * topic and (companion matrix change) increments Prometheus histograms. 18 */ 19(function (window, document) { 20 'use strict'; 21 22 var wv = window.webVitals; 23 // Bail quietly if the web-vitals lib or the required browser APIs are missing. 24 if (!wv || !navigator || typeof navigator.sendBeacon !== 'function') { 25 return; 26 } 27 28 var ENDPOINT = 'https://bi.connecteam.com/bi/api/Performance/Web/'; 29 var CATEGORY = 'web_vital'; 30 var MAX_BEACON_BYTES = 60 * 1024; // stay comfortably under the server's 70 KB cap 31 32 // Latest reading per metric name (LCP/INP/CLS/TTFB), keyed so re-reports overwrite. 33 var latest = {}; 34 // metric.id values already beaconed, so a second flush never double-sends a value. 35 var sentIds = {}; 36 37 // Canonical UUID (any version). The cta first-party ids are v4; reject anything malformed so 38 // we attach clean attribution or none at all. 39 var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; 40 function isUuid(v) { 41 return typeof v === 'string' && UUID_RE.test(v); 42 } 43 44 function readIdentity() { 45 var cta = window.cta || {}; 46 function safe(fn) { 47 try { 48 return typeof fn === 'function' ? fn.call(cta) : null; 49 } catch (e) { 50 return null; 51 } 52 } 53 return { 54 clientId: safe(cta.clientId), 55 sessionId: safe(cta.sessionId), 56 visitId: safe(cta.visitId), 57 }; 58 } 59 60 // Extract a small, useful attribution target (CSS selector) when the lib provides one. 61 function attributionTarget(metric) { 62 var a = metric.attribution; 63 if (!a) { 64 return undefined; 65 } 66 return a.element || a.largestShiftTarget || a.interactionTarget || undefined; 67 } 68 69 function onVital(metric) { 70 // CLS is a unitless score (~0.05); the timing vitals are milliseconds. 71 var value = metric.name === 'CLS' ? metric.value.toFixed(4) : String(Math.round(metric.value)); 72 73 var extra = { 74 rating: metric.rating, // 'good' | 'needs-improvement' | 'poor' 75 metric_id: metric.id, 76 navigationType: metric.navigationType, 77 }; 78 var target = attributionTarget(metric); 79 if (target) { 80 extra.target = String(target).slice(0, 200); 81 } 82 83 latest[metric.name] = { 84 type: 'eventHit', 85 hit: { 86 label: metric.name, // LCP | INP | CLS | TTFB 87 value: value, // string â matches PerformanceEventModel.value 88 category: CATEGORY, 89 clientHitStartTime: Date.now(), 90 host: window.location.href, 91 pageTitle: document.title, 92 extra: extra, 93 }, 94 }; 95 } 96 97 function flush() { 98 var events = []; 99 for (var name in latest) { 100 if (!latest.hasOwnProperty(name)) { 101 continue; 102 } 103 var ev = latest[name]; 104 var id = ev.hit.extra && ev.hit.extra.metric_id; 105 if (id && sentIds[id]) { 106 continue; // already sent this exact measurement 107 } 108 events.push(ev); 109 } 110 if (!events.length) { 111 return; 112 } 113 114 // sessionId/clientId are best-effort attribution only â the Core Web Vital (label/value/host) 115 // is the payload that matters, and the ingest endpoint tolerates null/absent identity (it 116 // coerces malformed ids to null; see matrix ReportHitsMetadata). So we send the vital 117 // regardless of identity, and attach only ids that are well-formed UUIDs â dropping a 118 // missing/garbage id client-side keeps the payload clean instead of relying on the server. 119 var identity = readIdentity(); 120 var metadata = {};
121 if (isUuid(identity.sessionId)) { 122 metadata.sessionId = identity.sessionId; 123 } 124 if (isUuid(identity.clientId)) { 125 metadata.clientId = identity.clientId; 126 } 127 if (isUuid(identity.visitId)) { 128 metadata.visitId = identity.visitId; 129 } 130 131 var body; 132 try { 133 body = JSON.stringify({ metadata: metadata, events: events }); 134 } catch (e) { 135 return; 136 } 137 if (body.length > MAX_BEACON_BYTES) { 138 return; 139 } 140 141 var ok = false; 142 try { 143 // Send with NO Content-Type header (empty-type Blob), NOT application/json. 144 // application/json is not CORS-safelisted, so a cross-origin POST (connecteam.com 145 // -> bi.connecteam.com) triggers a preflight that frequently cannot complete during 146 // visibilitychange/pagehide unload -- the beacon then reports success locally while 147 // the request never reaches the server, silently losing RUM. 148 // 149 // We must NOT switch to text/plain either: the ingest endpoint is FastAPI 150 // (body: ReportHitsBody = Body(...)), which only parses the body as JSON when 151 // Content-Type is application/json (or +json) OR *absent* -- text/plain yields a 422. 152 // An empty-type Blob makes the browser omit Content-Type entirely, which threads the 153 // needle: absent Content-Type => CORS "simple request" (no preflight) AND FastAPI 154 // still parses the body as JSON. No server change required. (Validated against 155 // fastapi==0.121.2, the version this endpoint runs.) 156 ok = navigator.sendBeacon(ENDPOINT, new Blob([body])); 157 } catch (e) { 158 ok = false; 159 } 160 // Fallback for when sendBeacon is unavailable or its queue is full: a keepalive fetch 161 // survives document unload. Same empty-type Blob body (no Content-Type, no preflight); 162 // mode:'no-cors' keeps the opaque response, which is fine for fire-and-forget. 163 if (!ok && typeof fetch === 'function') { 164 try { 165 fetch(ENDPOINT, { 166 method: 'POST', 167 body: new Blob([body]), 168 keepalive: true, 169 mode: 'no-cors', 170 }); 171 ok = true; 172 } catch (e) { 173 ok = false; 174 } 175 } 176 if (ok) { 177 // Mark these measurements sent so the pagehide flush doesn't repeat them. 178 for (var i = 0; i < events.length; i++) { 179 var mid = events[i].hit.extra && events[i].hit.extra.metric_id; 180 if (mid) { 181 sentIds[mid] = true; 182 } 183 } 184 } 185 } 186 187 // Register the vitals. reportAllChanges defaults to false, so each fires once with 188 // its final value (web-vitals uses buffered PerformanceObserver entries, so being 189 // loaded late/deferred still captures LCP/CLS from before this script ran). 190 try { 191 wv.onLCP(onVital); 192 wv.onCLS(onVital); 193 wv.onINP(onVital); 194 wv.onTTFB(onVital); 195 } catch (e) { 196 return; 197 } 198 199 // web-vitals finalises its metrics on visibility hidden (its listener runs before 200 // ours, since it was registered first above), then we beacon the batch. pagehide 201 // is a belt-and-suspenders flush for bfcache/unload; sentIds prevents duplicates. 202 document.addEventListener( 203 'visibilitychange', 204 function () { 205 if (document.visibilityState === 'hidden') { 206 flush(); 207 } 208 }, 209 { capture: true } 210 ); 211 window.addEventListener('pagehide', flush, { capture: true }); 212})(window, document);
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.