1/* Motion Analytics tracker. Cookie-free: no cookies, no localStorage, no 2 * fingerprinting. Visitor uniqueness is derived server-side from a hash 3 * salted with a random value generated fresh each day and destroyed within 4 * 48 hours, so once a day has passed its hashes cannot be reversed or 5 * correlated across days. 6 * 7 * Also collects real-user performance passively (hand-rolled 8 * PerformanceObservers, no web-vitals dependency): LCP, an INP 9 * approximation, CLS, TTFB/FCP and long-animation-frames from first 10 * contentful paint, plus the 11 * prefers-reduced-motion preference (one synchronous matchMedia read at 12 * init, no observer), sent as one "vitals" beacon per view on 13 * visibilitychange hidden (pagehide fallback) or SPA route change. 14 * Aggregate metrics only, no new identifiers. 15 * 16 * Usage: 17 * <script defer src="https://analytics.motion.dev/tracker.js" data-site="example.com"></script> 18 * 19 * Custom events: 20 * window.motionAnalytics.track("Plus Docs Footer") 21 * <button data-motion-event="Plus Docs Footer">â¦</button> // no JS needed 22 */ 23;(function () { 24 var script = document.currentScript 25 var endpoint = "https://analytics.motion.dev/collect" 26 var site = (script && script.getAttribute("data-site")) || location.hostname 27 var dev = script && script.hasAttribute("data-dev") 28 29 // No-op on local development hosts unless data-dev is present. 30 if (!dev && /^(localhost|127\.|0\.0\.0\.0|192\.168\.|\[::1\])/.test(location.hostname)) { 31 window.motionAnalytics = { track: function () {} } 32 return 33 } 34 35 // Honour an explicit opt-out. Global Privacy Control is a recognised CCPA 36 // opt-out signal; we run cookie-free and need no consent to measure, but we 37 // do not measure a visitor who has signalled they do not want to be. 38 if (navigator.globalPrivacyControl === true) { 39 window.motionAnalytics = { track: function () {} } 40 return 41 } 42 43 var lastPath = null 44 var prevPath = "" 45 46 /* Campaign params are captured once, from the entry URL, and attached to 47 * every pageview of this page session â entry attribution, like Plausible. 48 * Re-reading location.search per pageview would drop them as soon as an 49 * SPA navigation rewrites the query string. Cookie-free means no storage: 50 * a full page load on an untagged URL starts an untagged session. */ 51 var entry = new URLSearchParams(location.search) 52 var utm = { 53 us: entry.get("utm_source") || "", 54 um: entry.get("utm_medium") || "", 55 uc: entry.get("utm_campaign") || "", 56 ut: entry.get("utm_term") || "", 57 un: entry.get("utm_content") || "", 58 } 59 60 function send(type, name) { 61 var path = location.pathname || "/" 62 if (type === "pageview") { 63 // SPA route changes can fire pushState for the same URL; dedupe. 64 if (path === lastPath) return 65 prevPath = lastPath || sameOriginReferrerPath() 66 lastPath = path 67 } 68 var payload = JSON.stringify({ 69 site: site, 70 type: type, 71 name: name || "", 72 path: path, 73 prev: type === "pageview" ? prevPath || "" : lastPath || path, 74 ref: document.referrer || "", 75 us: utm.us, 76 um: utm.um, 77 uc: utm.uc, 78 ut: utm.ut, 79 un: utm.un, 80 }) 81 if (!(navigator.sendBeacon && navigator.sendBeacon(endpoint, payload))) { 82 fetch(endpoint, { method: "POST", body: payload, keepalive: true }).catch(function () {}) 83 } 84 } 85 86 function sameOriginReferrerPath() { 87 try { 88 var ref = new URL(document.referrer) 89 return ref.origin === location.origin ? ref.pathname : "" 90 } catch (e) { 91 return "" 92 } 93 } 94 95 // --- Real-user vitals ------------------------------------------------- 96 var supported = 97 (window.PerformanceObserver && PerformanceObserver.supportedEntryTypes) || [] 98 var lcp = 0 99 var inpDurations = [] 100 var cls = 0 101 var clsSupported = supported.indexOf("layout-shift") !== -1 ? 1 : 0 102 var loafCount = 0 103 var loafTotal = 0 104 var loafSupported = supported.indexOf("long-animation-frame") !== -1 ? 1 : 0 105 // prefers-reduced-motion: one synchronous read of an already-computed OS 106 // preference (no style/layout recalc, no observer). Read once at init and 107 // cached â the preference effectively never flips mid-view, so it rides 108 // every vitals beacon as a constant. 109 var reducedMotion = 110 window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 1 : 0 111 var loadMetricsSent = false 112 var viewDirty = false 113 var viewFlushed = false 114 // Tags every vitals beacon; grades read only this method. Keep in step with 115 // FIELD_METHOD in @motion/analytics-core. 116 var FIELD_METHOD = 2 117 var fcpTime = 0 118 119 function contentfulPaintTime() { 120 if (fcpTime) return fcpTime 121 var paint = performance.getEntriesByType && performance.getEntriesByType("paint") 122 if (paint) { 123 for (var i = 0; i < paint.length; i++) { 124 if (paint[i].name === "first-contentful-paint") fcpTime = paint[i].startTime 125 } 126 } 127 return fcpTime 128 } 129 130 function observe(type, buffered, cb, opts) { 131 if (supported.indexOf(type) === -1) return 132 try { 133 var observer = new PerformanceObserver(function (list) {
134 list.getEntries().forEach(cb) 135 }) 136 var options = opts || {} 137 options.type = type 138 options.buffered = buffered 139 observer.observe(options) 140 } catch (e) {} 141 } 142 143 observe("largest-contentful-paint", true, function (entry) { 144 lcp = entry.startTime 145 }) 146 // INP approximation: interaction durations >= 40ms; p98-style pick at send. 147 observe( 148 "event", 149 true, 150 function (entry) { 151 if (entry.interactionId && inpDurations.length < 200) { 152 inpDurations.push(entry.duration) 153 } 154 }, 155 { durationThreshold: 40 } 156 ) 157 observe("layout-shift", true, function (entry) { 158 if (!entry.hadRecentInput && entry.value) { 159 cls += entry.value 160 viewDirty = true 161 } 162 }) 163 observe("long-animation-frame", false, function (entry) { 164 // A frame that starts before content paints is mostly the wait for 165 // render-blocking CSS and fonts on a slow connection, not jank. 166 var fcp = contentfulPaintTime() 167 if (!fcp || entry.startTime < fcp) return 168 loafCount++ 169 loafTotal += entry.duration 170 viewDirty = true 171 }) 172 173 function pickInp() { 174 if (!inpDurations.length) return 0 175 var sorted = inpDurations.slice().sort(function (a, b) { 176 return a - b 177 }) 178 return sorted[Math.max(0, Math.ceil(sorted.length * 0.98) - 1)] 179 } 180 181 /* One beacon per view. The first carries the initial-load metrics 182 * (LCP/INP/TTFB/FCP â SPA views get CLS/LoAF deltas only; the dashboard 183 * methodology states this rather than pretending). */ 184 function sendVitals(path) { 185 // One beacon per view: re-hides without new deltas must not inflate 186 // the weighted view count with perfect zeros. 187 if (viewFlushed && !viewDirty) return 188 var m = { 189 cls: Math.round(cls * 1000), 190 cs: clsSupported, 191 lc: loafCount, 192 lt: Math.round(loafTotal), 193 ls: loafSupported, 194 mem: navigator.deviceMemory || 0, 195 prm: reducedMotion, 196 v: FIELD_METHOD, 197 } 198 if (!loadMetricsSent) { 199 var nav = performance.getEntriesByType && performance.getEntriesByType("navigation")[0] 200 m.lcp = Math.round(lcp) 201 m.inp = Math.round(pickInp()) 202 m.ttfb = nav ? Math.round(nav.responseStart) : 0 203 m.fcp = Math.round(contentfulPaintTime()) 204 } 205 var measurable = 206 m.lcp || m.inp || m.ttfb || m.fcp || m.cs || m.ls 207 if (!measurable && !viewDirty) return 208 loadMetricsSent = true 209 viewFlushed = true 210 cls = 0 211 loafCount = 0 212 loafTotal = 0 213 viewDirty = false 214 var payload = JSON.stringify({ site: site, type: "vitals", path: path, m: m }) 215 if (!(navigator.sendBeacon && navigator.sendBeacon(endpoint, payload))) { 216 fetch(endpoint, { method: "POST", body: payload, keepalive: true }).catch(function () {}) 217 } 218 } 219 220 document.addEventListener("visibilitychange", function () { 221 if (document.visibilityState === "hidden") sendVitals(lastPath || location.pathname || "/") 222 }) 223 window.addEventListener("pagehide", function () { 224 sendVitals(lastPath || location.pathname || "/") 225 }) 226 227 // SPA navigation: history API patch + popstate. 228 function onRouteChange() { 229 // History calls that keep the path (React Router's replaceState while it 230 // hydrates, hash and query updates) stay in the current view. Flushing on 231 // them would send LCP and INP before the page is interactive. 232 if ((location.pathname || "/") === lastPath) return 233 // The outgoing view's vitals flush before the path advances. 234 sendVitals(lastPath || location.pathname || "/") 235 viewFlushed = false 236 // pushState fires before the new page renders; wait a tick so 237 // location reflects the final URL. 238 setTimeout(function () { 239 send("pageview") 240 }, 0) 241 } 242 var pushState = history.pushState 243 history.pushState = function () { 244 pushState.apply(this, arguments) 245 onRouteChange() 246 } 247 var replaceState = history.replaceState 248 history.replaceState = function () { 249 replaceState.apply(this, arguments) 250 onRouteChange() 251 } 252 window.addEventListener("popstate", onRouteChange) 253 254 window.motionAnalytics = { 255 track: function (name) { 256 if (name) send("event", String(name).slice(0, 120)) 257 }, 258 } 259 260 /* Declarative events: a click on any element carrying [data-motion-event] 261 * (or nested inside one) fires a named custom event, no JS wiring needed. 262 * Bound in the capture phase so it still records when the click navigates 263 * the page away â the beacon is fire-and-forget via sendBeacon. Only 264 * reached off the dev no-op path, so localhost stays silent here too. */ 265 document.addEventListener( 266 "click", 267 function (e) { 268 var el = e.target && e.target.closest && e.target.closest("[data-motion-event]") 269 if (!el) return 270 var name = el.getAttribute("data-motion-event") 271 if (name) send("event", name.slice(0, 120)) 272 }, 273 true 274 ) 275 276 send("pageview") 277})()
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.