PageSourceSearch

https://analytics.motion.dev/tracker.js

js motion.dev collected 2026-10-02 02:34:25 UTC 10,004 bytes, 277 lines download raw bytes

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.