tracker.js 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750
  1. /**
  2. * Copyright (c) UNA, Inc - https://una.io
  3. * MIT License - https://opensource.org/licenses/MIT
  4. *
  5. * @defgroup Analytics Analytics
  6. * @ingroup UnaModules
  7. *
  8. * @{
  9. */
  10. /**
  11. * Pageviews, engaged time, custom events, errors and web vitals from the classic UI.
  12. *
  13. * Sends a pageview when a page loads and after every htmx navigation (not on preload), and the
  14. * time the page was visible when it is hidden or left. Custom events:
  15. * bx_analytics.track('signup_click', {plan: 'pro'});
  16. * Clicks on links to other sites and to files are sent as the custom events `outbound_link` and
  17. * `file_download`, with the link (without its query string) in the `link` property.
  18. * Errors the page catches itself:
  19. * bx_analytics.trackError(oError);
  20. * Uncaught errors, unhandled rejections, failed htmx requests (500 and up) and the site's own
  21. * scripts and styles that fail to load are sent when the site tracks errors: the same error
  22. * once per page view with a count of repeats, at most 10 different ones, without query strings.
  23. * Web vitals (TTFB, FCP, LCP, CLS, INP) are measured on a share of page loads and sent once,
  24. * with the time the page was visible; an htmx navigation sends its load time, CLS and INP.
  25. * Stores nothing in the browser unless the site uses persistent visitor ids: then a random id in
  26. * localStorage, and in the bx_analytics_vid cookie for the site's own requests, so sign-ups and
  27. * other actions join this browser's visit. To stop counting
  28. * your own visits in this browser: localStorage.setItem('bx_analytics_ignore', '1').
  29. */
  30. (function (window, document) {
  31. 'use strict';
  32. var oScript = document.currentScript || document.querySelector('script[data-bx-analytics-endpoint]');
  33. if (!oScript || window.bx_analytics_tracker)
  34. return;
  35. var sEndpoint = oScript.getAttribute('data-bx-analytics-endpoint');
  36. var sPlatform = oScript.getAttribute('data-bx-analytics-platform') || 'core-web';
  37. var bPersistent = oScript.getAttribute('data-bx-analytics-identity') === 'persistent';
  38. var bRespectDnt = oScript.getAttribute('data-bx-analytics-dnt') === '1';
  39. var bTrackErrors = oScript.getAttribute('data-bx-analytics-errors') === '1';
  40. var iVitalsSample = parseInt(oScript.getAttribute('data-bx-analytics-vitals') || '0', 10) || 0;
  41. var oNavigator = window.navigator || {};
  42. var oPerformance = window.performance;
  43. function storageGet(sKey) {
  44. try {
  45. return window.localStorage.getItem(sKey);
  46. } catch (e) {
  47. return null;
  48. }
  49. }
  50. function isDisabled() {
  51. if (!sEndpoint || oNavigator.webdriver)
  52. return true;
  53. if (bRespectDnt && (oNavigator.doNotTrack === '1' || window.doNotTrack === '1' || oNavigator.globalPrivacyControl === true))
  54. return true;
  55. return storageGet('bx_analytics_ignore') === '1';
  56. }
  57. function getVisitorId() {
  58. if (!bPersistent)
  59. return '';
  60. var sId = storageGet('bx_analytics_vid');
  61. if (sId && /^[a-f0-9]{32}$/.test(sId))
  62. return sId;
  63. sId = '';
  64. var aBytes = new Uint8Array(16);
  65. (window.crypto || window.msCrypto).getRandomValues(aBytes);
  66. for (var i = 0; i < aBytes.length; i++)
  67. sId += ('0' + aBytes[i].toString(16)).slice(-2);
  68. try {
  69. window.localStorage.setItem('bx_analytics_vid', sId);
  70. } catch (e) {
  71. return '';
  72. }
  73. return sId;
  74. }
  75. var bDisabled = isDisabled();
  76. var sVisitorId = bDisabled ? '' : getVisitorId();
  77. /**
  78. * The visitor id for the site's own requests (persistent mode), or none: a cookie left
  79. * from persistent mode, or from before tracking was turned off here, is removed.
  80. */
  81. function setVisitorCookie(sId) {
  82. var sCookie = 'bx_analytics_vid=' + sId + '; path=/; SameSite=Lax; max-age=' + (sId ? 34560000 : 0);
  83. if (window.location.protocol === 'https:')
  84. sCookie += '; Secure';
  85. try {
  86. document.cookie = sCookie;
  87. } catch (e) {
  88. // cookies blocked: site actions are counted outside the visit
  89. }
  90. }
  91. function hasVisitorCookie() {
  92. try {
  93. return /(?:^|;\s*)bx_analytics_vid=/.test(document.cookie || '');
  94. } catch (e) {
  95. return false;
  96. }
  97. }
  98. if (sVisitorId)
  99. setVisitorCookie(sVisitorId);
  100. else if (hasVisitorCookie())
  101. setVisitorCookie('');
  102. var sReferrer = document.referrer;
  103. var sCurrentUrl = '';
  104. var iVisibleSince = 0;
  105. var iEngaged = 0;
  106. function send(aHits, bLeaving) {
  107. if (bDisabled)
  108. return;
  109. // errors waiting for their batch go with whatever is sent
  110. aHits = aHits.concat(takeErrors(bLeaving));
  111. if (!aHits.length)
  112. return;
  113. var oPayload = {
  114. platform: sPlatform,
  115. language: oNavigator.language || '',
  116. screen: window.screen ? window.screen.width + 'x' + window.screen.height : '',
  117. hits: aHits
  118. };
  119. if (sReferrer)
  120. oPayload.referrer = sReferrer;
  121. if (sVisitorId)
  122. oPayload.vid = sVisitorId;
  123. var sBody = JSON.stringify(oPayload);
  124. // text/plain keeps the beacon a simple request
  125. if (oNavigator.sendBeacon && oNavigator.sendBeacon(sEndpoint, new Blob([sBody], {type: 'text/plain'})))
  126. return;
  127. if (window.fetch)
  128. window.fetch(sEndpoint, {method: 'POST', body: sBody, keepalive: true, credentials: 'same-origin', headers: {'Content-Type': 'text/plain'}}).catch(function () {});
  129. }
  130. function engagedNow() {
  131. var iTotal = iEngaged;
  132. if (iVisibleSince)
  133. iTotal += Date.now() - iVisibleSince;
  134. return iTotal;
  135. }
  136. function leaveHit() {
  137. var iMs = engagedNow();
  138. iEngaged = 0;
  139. iVisibleSince = document.visibilityState === 'visible' ? Date.now() : 0;
  140. return iMs > 0 && sCurrentUrl ? {type: 'leave', url: sCurrentUrl, engaged: iMs} : null;
  141. }
  142. /**
  143. * What a page left behind: engaged time and its vitals, sent together.
  144. */
  145. function leaveHits() {
  146. var aHits = [];
  147. var oLeave = leaveHit();
  148. if (oLeave)
  149. aHits.push(oLeave);
  150. var oVitals = takeVitals();
  151. if (oVitals)
  152. aHits.push(oVitals);
  153. return aHits;
  154. }
  155. function pageview(sNav) {
  156. var sUrl = window.location.href;
  157. if (sUrl === sCurrentUrl)
  158. return;
  159. var aHits = [];
  160. var bFirst = !sCurrentUrl;
  161. if (!bFirst) {
  162. aHits = leaveHits().concat(takeErrors(true));
  163. resetErrors();
  164. startSoftView(sNav || 'soft');
  165. }
  166. // after the first page, the referrer of a page is the page before it
  167. if (sCurrentUrl)
  168. sReferrer = sCurrentUrl;
  169. sCurrentUrl = sUrl;
  170. aHits.push({type: 'pageview', url: sUrl, title: document.title});
  171. send(aHits);
  172. }
  173. /*
  174. * Errors
  175. */
  176. var MAX_ERRORS = 10;
  177. var oErrors = {};
  178. var aErrorKeys = [];
  179. var iErrorTimer = 0;
  180. function isSameOrigin(sUrl) {
  181. try {
  182. return new URL(sUrl, window.location.href).origin === window.location.origin;
  183. } catch (e) {
  184. return false;
  185. }
  186. }
  187. /**
  188. * A URL without its query string and fragment (only the page parameters `i` and `r` stay),
  189. * and without the site's own origin.
  190. */
  191. function cleanUrl(sUrl) {
  192. var oUrl;
  193. try {
  194. oUrl = new URL(sUrl, window.location.href);
  195. } catch (e) {
  196. return String(sUrl || '').replace(/[?#].*$/, '');
  197. }
  198. var aKeep = [];
  199. ['i', 'r'].forEach(function (sKey) {
  200. var sValue = oUrl.searchParams.get(sKey);
  201. if (sValue)
  202. aKeep.push(sKey + '=' + encodeURIComponent(sValue).replace(/%2F/gi, '/'));
  203. });
  204. return (oUrl.origin === window.location.origin ? '' : oUrl.origin) + oUrl.pathname + (aKeep.length ? '?' + aKeep.join('&') : '');
  205. }
  206. function pageUrl() {
  207. var sPath = cleanUrl(window.location.href);
  208. return window.location.origin + sPath;
  209. }
  210. /**
  211. * Text with every URL in it cleaned; a stack frame's :line:column stays.
  212. */
  213. function cleanText(s) {
  214. return String(s || '').replace(/\b(?:https?|wss?):\/\/[^\s()<>"'`]+/gi, function (sUrl) {
  215. var aPosition = sUrl.match(/(:\d+){1,2}$/);
  216. var sPosition = aPosition ? aPosition[0] : '';
  217. return cleanUrl(sPosition ? sUrl.slice(0, -sPosition.length) : sUrl) + sPosition;
  218. });
  219. }
  220. function isExtension(s) {
  221. return /\b[a-z-]*extension:\/\//i.test(String(s || ''));
  222. }
  223. /**
  224. * At most 30 frames, without the frames of browser extensions.
  225. */
  226. function cleanStack(sStack) {
  227. if (typeof sStack !== 'string' || !sStack)
  228. return '';
  229. return cleanText(sStack.split('\n').filter(function (sLine) {
  230. return sLine.trim() !== '' && !isExtension(sLine);
  231. }).slice(0, 30).join('\n')).slice(0, 8000);
  232. }
  233. /**
  234. * Message and stack of whatever was thrown.
  235. */
  236. function describe(mixedError, sPrefix) {
  237. if (mixedError && typeof mixedError === 'object' && typeof mixedError.message === 'string')
  238. return {message: (mixedError.name ? mixedError.name + ': ' : '') + mixedError.message, stack: mixedError.stack};
  239. // an object that is no Error: its kind and keys, not its values (a form or an API
  240. // payload would carry what someone typed)
  241. var sText;
  242. try {
  243. if (Array.isArray(mixedError))
  244. sText = 'Array(' + mixedError.length + ')';
  245. else if (mixedError && typeof mixedError === 'object') {
  246. var aKeys = Object.keys(mixedError);
  247. sText = (mixedError.constructor && mixedError.constructor.name || 'Object') + '{' + aKeys.slice(0, 10).join(',') + (aKeys.length > 10 ? ',...' : '') + '}';
  248. }
  249. else
  250. sText = String(mixedError);
  251. } catch (e) {
  252. sText = Object.prototype.toString.call(mixedError);
  253. }
  254. return {message: (sPrefix || '') + String(sText).slice(0, 200), stack: ''};
  255. }
  256. function captureError(sKind, oData) {
  257. if (bDisabled || !bTrackErrors)
  258. return;
  259. var sStack = cleanStack(oData.stack);
  260. var sMessage = cleanText(oData.message).trim().slice(0, 500);
  261. var sSource = cleanText(oData.source || '');
  262. if (!sMessage || (/^Script error\.?$/i.test(sMessage) && !sStack))
  263. return;
  264. // an extension's error, or one whose stack was all extension frames
  265. if (isExtension(oData.source) || (oData.stack && !sStack && isExtension(oData.stack)))
  266. return;
  267. var sKey = [sKind, sMessage, sSource, oData.line || 0].join('|');
  268. var oItem = oErrors[sKey];
  269. if (!oItem) {
  270. if (aErrorKeys.length >= MAX_ERRORS)
  271. return;
  272. aErrorKeys.push(sKey);
  273. oItem = oErrors[sKey] = {
  274. hit: {
  275. type: 'error',
  276. url: pageUrl(),
  277. kind: sKind,
  278. message: sMessage,
  279. source: sSource || undefined,
  280. line: oData.line || undefined,
  281. col: oData.col || undefined,
  282. stack: sStack || undefined,
  283. status: oData.status || undefined,
  284. fatal: oData.fatal ? 1 : undefined
  285. },
  286. pending: 0,
  287. sent: false,
  288. time: 0
  289. };
  290. }
  291. oItem.pending = Math.min(100, oItem.pending + 1);
  292. oItem.time = Date.now();
  293. // a new error goes out within 2 seconds; its repeats go with the page's last beacon
  294. if (!oItem.sent && !iErrorTimer)
  295. iErrorTimer = setTimeout(function () {
  296. iErrorTimer = 0;
  297. send([]);
  298. }, 2000);
  299. }
  300. /**
  301. * Error hits to send: new errors, and the repeats of sent ones when the page is left.
  302. */
  303. function takeErrors(bLeaving) {
  304. var aHits = [];
  305. aErrorKeys.forEach(function (sKey) {
  306. var oItem = oErrors[sKey];
  307. if (!oItem.pending || (oItem.sent && !bLeaving))
  308. return;
  309. var oHit = {};
  310. for (var sField in oItem.hit)
  311. oHit[sField] = oItem.hit[sField];
  312. oHit.repeats = oItem.pending;
  313. oHit.age = Math.max(0, Date.now() - oItem.time);
  314. oItem.pending = 0;
  315. oItem.sent = true;
  316. aHits.push(oHit);
  317. });
  318. return aHits;
  319. }
  320. function resetErrors() {
  321. oErrors = {};
  322. aErrorKeys = [];
  323. }
  324. function onHttpError(oEvent) {
  325. var oDetail = oEvent.detail || {};
  326. var oCtx = oDetail.ctx || {};
  327. var iStatus = oCtx.response ? oCtx.response.status : (oDetail.xhr ? oDetail.xhr.status : 0);
  328. if (!iStatus || iStatus < 500)
  329. return;
  330. var sUrl = oCtx.request && oCtx.request.action ? oCtx.request.action : (oDetail.pathInfo && oDetail.pathInfo.requestPath ? oDetail.pathInfo.requestPath : (oDetail.xhr && oDetail.xhr.responseURL) || '');
  331. var sPath = cleanUrl(sUrl);
  332. captureError('http', {message: 'HTTP ' + iStatus + ' ' + sPath, source: sPath, status: iStatus});
  333. }
  334. /*
  335. * Web vitals
  336. */
  337. // a roll in [0, 100) from the CSPRNG getVisitorId() uses; no vitals without one
  338. var oCrypto = window.crypto || window.msCrypto;
  339. var fVitalsRoll = oCrypto && oCrypto.getRandomValues ? oCrypto.getRandomValues(new Uint32Array(1))[0] / 4294967296 * 100 : 100;
  340. var bVitals = !bDisabled && iVitalsSample > 0 && fVitalsRoll < iVitalsSample && typeof window.PerformanceObserver === 'function' && !!oPerformance && typeof oPerformance.now === 'function';
  341. var aSupported = bVitals && window.PerformanceObserver.supportedEntryTypes ? window.PerformanceObserver.supportedEntryTypes : [];
  342. var oView = null;
  343. var aObservers = [];
  344. var iFirstHidden = document.visibilityState === 'hidden' ? 0 : Infinity;
  345. var iSoftStart = 0;
  346. var iLastSettle = 0;
  347. function getNavigation() {
  348. var aEntries = oPerformance && oPerformance.getEntriesByType ? oPerformance.getEntriesByType('navigation') : [];
  349. return aEntries && aEntries[0] ? aEntries[0] : null;
  350. }
  351. function getActivationStart() {
  352. var oNavigation = getNavigation();
  353. return oNavigation && oNavigation.activationStart > 0 ? oNavigation.activationStart : 0;
  354. }
  355. function newView(sNav, iStart, bHard) {
  356. oView = {nav: sNav, url: pageUrl(), start: iStart, hard: bHard, metrics: {}, lcpDone: !bHard, cls: 0, clsWindow: 0, clsFirst: 0, clsLast: 0, inp: {}, interactions: 0, waitLoad: false, sent: false};
  357. }
  358. function startSoftView(sNav) {
  359. if (!bVitals)
  360. return;
  361. // the navigation's request, when htmx made one for it a moment ago
  362. var iNow = oPerformance.now();
  363. var iStart = iSoftStart && iNow - iSoftStart < 30000 ? iSoftStart : 0;
  364. iSoftStart = 0;
  365. newView(sNav, iStart || iNow, false);
  366. if (iStart && iLastSettle > iStart)
  367. oView.metrics.screen_load = iLastSettle - iStart;
  368. else if (iStart)
  369. oView.waitLoad = true;
  370. }
  371. function observe(sType, fCallback, oOptions) {
  372. if (aSupported.indexOf(sType) === -1)
  373. return false;
  374. try {
  375. var oParams = {type: sType, buffered: true};
  376. for (var sKey in oOptions || {})
  377. oParams[sKey] = oOptions[sKey];
  378. var oObserver = new window.PerformanceObserver(function (oList) {
  379. oList.getEntries().forEach(fCallback);
  380. });
  381. oObserver.observe(oParams);
  382. aObservers.push({observer: oObserver, callback: fCallback});
  383. } catch (e) {
  384. return false;
  385. }
  386. return true;
  387. }
  388. /**
  389. * Entries recorded but not yet delivered (observers are called in a later task), so the
  390. * last shifts, interaction and paint count before a view's vitals are read.
  391. */
  392. function flushObservers() {
  393. aObservers.forEach(function (oItem) {
  394. try {
  395. oItem.observer.takeRecords().forEach(oItem.callback);
  396. } catch (e) {}
  397. });
  398. }
  399. function onInteraction(oEntry) {
  400. if (!oView || !oEntry.interactionId || oEntry.startTime < oView.start)
  401. return;
  402. var iId = oEntry.interactionId;
  403. if (oView.inp[iId] === undefined)
  404. oView.interactions++;
  405. if (oView.inp[iId] === undefined || oEntry.duration > oView.inp[iId])
  406. oView.inp[iId] = oEntry.duration;
  407. // only the longest ones matter: keep the list short
  408. var aIds = Object.keys(oView.inp);
  409. if (aIds.length > 50) {
  410. aIds.sort(function (a, b) { return oView.inp[b] - oView.inp[a]; });
  411. aIds.slice(10).forEach(function (sId) { delete oView.inp[sId]; });
  412. }
  413. }
  414. /**
  415. * The view's vitals hit, once; null when nothing was measured.
  416. */
  417. function takeVitals() {
  418. if (!oView || oView.sent)
  419. return null;
  420. flushObservers();
  421. oView.sent = true;
  422. var oMetrics = {};
  423. for (var sKey in oView.metrics)
  424. oMetrics[sKey] = oView.metrics[sKey];
  425. if (oView.hard) {
  426. var oNavigation = getNavigation();
  427. if (oNavigation && oNavigation.responseStart > 0)
  428. oMetrics.ttfb = Math.max(0, oNavigation.responseStart - getActivationStart());
  429. }
  430. if (aSupported.indexOf('layout-shift') !== -1)
  431. oMetrics.cls = Math.round(oView.cls * 10000) / 10000;
  432. // INP: the longest interaction, or a high percentile of many (one in 50 left out)
  433. var aDurations = Object.keys(oView.inp).map(function (sId) { return oView.inp[sId]; }).sort(function (a, b) { return b - a; });
  434. var iCount = Math.max(oView.interactions, oView.hard && oPerformance.interactionCount ? oPerformance.interactionCount : 0);
  435. if (aDurations.length)
  436. oMetrics.inp = aDurations[Math.min(aDurations.length - 1, Math.min(9, Math.floor(iCount / 50)))];
  437. var bAny = false;
  438. for (sKey in oMetrics) {
  439. if (sKey !== 'cls')
  440. oMetrics[sKey] = Math.round(oMetrics[sKey]);
  441. bAny = true;
  442. }
  443. return bAny ? {type: 'vitals', url: oView.url, nav: oView.nav, metrics: oMetrics} : null;
  444. }
  445. function stopLcp() {
  446. if (!oView || oView.lcpDone)
  447. return;
  448. // a paint before the input may not be delivered yet
  449. flushObservers();
  450. oView.lcpDone = true;
  451. }
  452. function startVitals() {
  453. var oNavigation = getNavigation();
  454. var sNav = oNavigation && oNavigation.type ? String(oNavigation.type).replace('-', '_') : 'navigate';
  455. if (document.prerendering || getActivationStart() > 0)
  456. sNav = 'prerender';
  457. newView(sNav, 0, true);
  458. observe('paint', function (oEntry) {
  459. if (oView && oView.hard && oEntry.name === 'first-contentful-paint' && oEntry.startTime < iFirstHidden)
  460. oView.metrics.fcp = Math.max(0, oEntry.startTime - getActivationStart());
  461. });
  462. // the largest paint until the visitor first clicks or types, or leaves
  463. observe('largest-contentful-paint', function (oEntry) {
  464. if (oView && oView.hard && !oView.lcpDone && oEntry.startTime < iFirstHidden)
  465. oView.metrics.lcp = Math.max(0, oEntry.startTime - getActivationStart());
  466. });
  467. ['pointerdown', 'keydown'].forEach(function (sEvent) {
  468. window.addEventListener(sEvent, stopLcp, {capture: true, passive: true});
  469. });
  470. // layout shifts in session windows (less than 1 s apart, 5 s at most): the largest counts
  471. observe('layout-shift', function (oEntry) {
  472. if (!oView || oEntry.hadRecentInput || oEntry.startTime < oView.start)
  473. return;
  474. if (oView.clsWindow && oEntry.startTime - oView.clsLast < 1000 && oEntry.startTime - oView.clsFirst < 5000)
  475. oView.clsWindow += oEntry.value;
  476. else {
  477. oView.clsWindow = oEntry.value;
  478. oView.clsFirst = oEntry.startTime;
  479. }
  480. oView.clsLast = oEntry.startTime;
  481. oView.cls = Math.max(oView.cls, oView.clsWindow);
  482. });
  483. observe('event', onInteraction, {durationThreshold: 40});
  484. observe('first-input', onInteraction);
  485. // htmx navigations: from the page's request to its content settled
  486. var fRequest = function (oEvent) {
  487. var oCtx = oEvent.detail && oEvent.detail.ctx;
  488. var oHeaders = oCtx && oCtx.request && oCtx.request.headers;
  489. if (!oHeaders || !oHeaders['HX-Request-Type'] || oHeaders['HX-Request-Type'] === 'full')
  490. iSoftStart = oPerformance.now();
  491. };
  492. var fSettle = function () {
  493. iLastSettle = oPerformance.now();
  494. if (oView && oView.waitLoad) {
  495. oView.metrics.screen_load = iLastSettle - oView.start;
  496. oView.waitLoad = false;
  497. }
  498. };
  499. document.addEventListener('htmx:before:request', fRequest);
  500. document.addEventListener('htmx:beforeRequest', fRequest);
  501. document.addEventListener('htmx:after:settle', fSettle);
  502. document.addEventListener('htmx:afterSettle', fSettle);
  503. // a page restored from the back-forward cache is a new view without a load
  504. window.addEventListener('pageshow', function (oEvent) {
  505. if (oEvent.persisted)
  506. newView('restore', oPerformance.now(), false);
  507. });
  508. }
  509. window.bx_analytics_tracker = true;
  510. /**
  511. * Public API for custom events. `props` holds short scalar values.
  512. */
  513. window.bx_analytics = window.bx_analytics || {};
  514. window.bx_analytics.track = function (sName, oProps, oOptions) {
  515. if (!sName)
  516. return;
  517. send([{
  518. type: 'event',
  519. name: String(sName),
  520. url: window.location.href,
  521. props: oProps || undefined,
  522. value: oOptions && typeof oOptions.value === 'number' ? oOptions.value : undefined,
  523. non_interactive: oOptions && oOptions.nonInteractive ? 1 : undefined
  524. }]);
  525. };
  526. /**
  527. * An error the page caught itself. Options: `fatal` when the page can't go on.
  528. */
  529. window.bx_analytics.trackError = function (mixedError, oOptions) {
  530. var oError = describe(mixedError);
  531. captureError('js', {message: oError.message, stack: oError.stack, fatal: oOptions && oOptions.fatal});
  532. };
  533. if (bDisabled)
  534. return;
  535. iVisibleSince = document.visibilityState === 'visible' ? Date.now() : 0;
  536. document.addEventListener('visibilitychange', function () {
  537. if (document.visibilityState === 'hidden') {
  538. iFirstHidden = Math.min(iFirstHidden, oPerformance && oPerformance.now ? oPerformance.now() : 0);
  539. stopLcp();
  540. send(leaveHits(), true);
  541. }
  542. else if (!iVisibleSince) {
  543. iVisibleSince = Date.now();
  544. }
  545. });
  546. window.addEventListener('pagehide', function () {
  547. stopLcp();
  548. send(leaveHits(), true);
  549. });
  550. if (bTrackErrors) {
  551. // capture: script and stylesheet elements that fail to load don't bubble
  552. window.addEventListener('error', function (oEvent) {
  553. var oTarget = oEvent.target;
  554. if (oTarget && oTarget !== window && oTarget.nodeType === 1) {
  555. var sUrl = oTarget.tagName === 'SCRIPT' ? oTarget.src : (oTarget.tagName === 'LINK' && /stylesheet/i.test(oTarget.rel) ? oTarget.href : '');
  556. // the site's own files: a blocked tracker or ad elsewhere is not the site's error
  557. if (sUrl && isSameOrigin(sUrl))
  558. captureError('resource', {message: 'Failed to load ' + (oTarget.tagName === 'SCRIPT' ? 'script' : 'stylesheet') + ' ' + cleanUrl(sUrl), source: sUrl});
  559. return;
  560. }
  561. captureError('js', {
  562. message: oEvent.message || (oEvent.error && oEvent.error.message) || '',
  563. source: oEvent.filename,
  564. line: oEvent.lineno,
  565. col: oEvent.colno,
  566. stack: oEvent.error && oEvent.error.stack
  567. });
  568. }, true);
  569. window.addEventListener('unhandledrejection', function (oEvent) {
  570. var oError = describe(oEvent.reason, 'Non-Error promise rejection: ');
  571. captureError('promise', {message: oError.message, stack: oError.stack});
  572. });
  573. document.addEventListener('htmx:response:error', onHttpError);
  574. document.addEventListener('htmx:responseError', onHttpError);
  575. }
  576. if (bVitals)
  577. startVitals();
  578. // htmx navigation pushes the new URL after swapping the page in (htmx 4 and older names)
  579. ['htmx:after:history:push', 'htmx:after:history:replace', 'htmx:pushedIntoHistory', 'htmx:replacedInHistory'].forEach(function (sEvent) {
  580. document.addEventListener(sEvent, function () {
  581. setTimeout(pageview, 0);
  582. });
  583. });
  584. window.addEventListener('popstate', function () {
  585. setTimeout(function () {
  586. pageview('back_forward');
  587. }, 0);
  588. });
  589. /*
  590. * Outbound links and downloads
  591. */
  592. var rDownload = /\.(pdf|zip|rar|7z|gz|tgz|tar|dmg|exe|msi|pkg|apk|ipa|docx?|xlsx?|pptx?|odt|ods|csv|txt|rtf|mp3|wav|flac|mp4|mov|avi|mkv|webm|epub)$/i;
  593. /**
  594. * A click on a link to another site (`outbound_link`) or to a file (`file_download`: a
  595. * `download` attribute or a file extension, in the path or in UNA's `r` and `f` parameters
  596. * when permalinks are off), sent as a custom event with the link without its query string
  597. * and with a download route's token masked. The beacon survives the navigation the click
  598. * starts.
  599. */
  600. function onLinkClick(oEvent) {
  601. if (oEvent.type === 'auxclick' && oEvent.button !== 1)
  602. return;
  603. var oLink = oEvent.target && oEvent.target.closest ? oEvent.target.closest('a[href]') : null;
  604. if (!oLink)
  605. return;
  606. var sHref = oLink.getAttribute('href') || '';
  607. if (!sHref || sHref.charAt(0) === '#' || /^(javascript|mailto|tel|sms|data|blob):/i.test(sHref))
  608. return;
  609. var oUrl;
  610. try {
  611. oUrl = new URL(oLink.href, window.location.href);
  612. } catch (e) {
  613. return;
  614. }
  615. if (oUrl.protocol !== 'http:' && oUrl.protocol !== 'https:')
  616. return;
  617. var sName = '';
  618. if (oLink.hasAttribute('download') || rDownload.test(oUrl.pathname) || rDownload.test(oUrl.searchParams.get('r') || '') || rDownload.test(oUrl.searchParams.get('f') || ''))
  619. sName = 'file_download';
  620. else if (oUrl.origin !== window.location.origin)
  621. sName = 'outbound_link';
  622. if (!sName)
  623. return;
  624. // files/download/<token>/<id>.<ext>: the token opens the file, keep it out of reports
  625. var sLinkUrl = cleanUrl(oUrl.href).replace(/(\/download\/)[^\/?#&]+\//, '$1*/');
  626. send([{type: 'event', name: sName, url: window.location.href, props: {link: sLinkUrl}}]);
  627. }
  628. // on the document, so links htmx swaps in later count too
  629. document.addEventListener('click', onLinkClick, true);
  630. document.addEventListener('auxclick', onLinkClick, true);
  631. pageview();
  632. })(window, document);
  633. /** @} */