tracker.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. import { fetcher, setAnalyticsVisitorId, setServerErrorListener } from 'app/lib/fetcher';
  2. import { persistedGet, persistedSet } from 'app/lib/util/storage';
  3. import { getClient, getFallbackUrl, getInitialReferrer, isClientAvailable, isTrackingBlocked } from './client';
  4. import {
  5. acceptHealthHit,
  6. captureError,
  7. configureHealth,
  8. resetViewErrors,
  9. setHealthSink,
  10. takeErrorHits,
  11. takeVitalsHit,
  12. } from './health';
  13. import { cleanUrl } from './privacy';
  14. import type { AnalyticsHit, AnalyticsPayload, AnalyticsProps, TrackerConfig, TrackErrorOptions, TrackEventOptions } from './analytics.types';
  15. /**
  16. * Pageviews (screen views), engaged time and custom events for UNA's analytics module
  17. * (bx_analytics). The same model as the classic UI's tracker (modules/boonex/analytics/js/
  18. * tracker.js): a pageview on every page, a `leave` hit with the visible time when the page
  19. * is left or hidden, events on demand.
  20. *
  21. * Errors and vitals (`health.ts`) go out with these hits: errors within seconds and with the
  22. * page's leave hit, vitals one per request.
  23. *
  24. * Settings come once per app start from `bx_analytics/get_tracker_config`. When the module
  25. * is missing, tracking is off for this client, or UNA answers a hit with an error, nothing
  26. * more is sent until the app (page) reloads. Failures are silent and nothing here throws.
  27. */
  28. const CONFIG_URL = '/api.php?r=bx_analytics/get_tracker_config';
  29. const COLLECT_URL = '/api.php?r=bx_analytics/collect';
  30. /** BxAnalyticsConfig MAX_HITS_PER_REQUEST */
  31. const MAX_HITS = 20;
  32. /** BxAnalyticsConfig MAX_ERRORS_PER_REQUEST and MAX_VITALS_PER_REQUEST: UNA drops the rest. */
  33. const MAX_PER_REQUEST: Partial<Record<AnalyticsHit['type'], number>> = { error: 10, vitals: 1 };
  34. /** UNA ignores `age` from an hour on. */
  35. const MAX_AGE_MS = 3600 * 1000;
  36. /** After a network failure, wait this long before asking for the settings again. */
  37. const CONFIG_RETRY_MS = 60 * 1000;
  38. const REQUEST_TIMEOUT_MS = 10 * 1000;
  39. const VISITOR_ID_KEY = 'analytics_vid';
  40. /** The tracker's own requests: their failures aren't reported, or a broken collector would report itself. */
  41. const OWN_REQUEST = /[?&]r=bx_analytics\/(?:collect|get_tracker_config)\b/;
  42. type State = 'idle' | 'loading' | 'on' | 'off';
  43. type QueuedHit = { hit: AnalyticsHit; at: number };
  44. let state: State = 'idle';
  45. let config: TrackerConfig | null = null;
  46. let configRetryAt = 0;
  47. let pending: QueuedHit[] = [];
  48. let visitorId = '';
  49. let referrer: string | null = null;
  50. let started = false;
  51. let currentUrl = '';
  52. let currentTitle = '';
  53. /** When the page became visible (0 while hidden), and visible time collected before that. */
  54. let visibleSince = 0;
  55. let engagedMs = 0;
  56. let hiddenAt = 0;
  57. function randomId(): string {
  58. const cryptoApi = (globalThis as { crypto?: Crypto }).crypto;
  59. if (!cryptoApi?.getRandomValues) return '';
  60. const bytes = new Uint8Array(16);
  61. cryptoApi.getRandomValues(bytes);
  62. return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('');
  63. }
  64. /** The visitor id kept on this device, for sites in persistent identity mode. */
  65. async function getVisitorId(): Promise<string> {
  66. const stored = await persistedGet(VISITOR_ID_KEY);
  67. if (typeof stored === 'string' && /^[a-f0-9]{32}$/.test(stored)) return stored;
  68. const id = randomId();
  69. if (id) await persistedSet(VISITOR_ID_KEY, id);
  70. return id;
  71. }
  72. function turnOff(): void {
  73. state = 'off';
  74. pending = [];
  75. configureHealth(null);
  76. setAnalyticsVisitorId('');
  77. }
  78. function toNumber(value: unknown, min: number, max: number, fallback: number): number {
  79. const number = Number(value);
  80. return Number.isFinite(number) ? Math.min(max, Math.max(min, number)) : fallback;
  81. }
  82. async function loadConfig(): Promise<void> {
  83. state = 'loading';
  84. try {
  85. const response = await fetcher(
  86. `${CONFIG_URL}&params[]=${encodeURIComponent(getClient().platform)}`,
  87. false,
  88. { silent: true, maxAttempts: 1, timeoutMs: REQUEST_TIMEOUT_MS },
  89. );
  90. const data = response?.status === 200 ? response.data : null;
  91. if (!data || !data.enabled || isTrackingBlocked(!!data.respect_dnt)) {
  92. turnOff();
  93. return;
  94. }
  95. config = {
  96. enabled: true,
  97. persistent: !!data.persistent,
  98. respect_dnt: !!data.respect_dnt,
  99. session_timeout: Number(data.session_timeout) > 0 ? Number(data.session_timeout) : 1800,
  100. errors: !!data.errors,
  101. errors_ignore: Array.isArray(data.errors_ignore)
  102. ? data.errors_ignore.filter((pattern: unknown): pattern is string => typeof pattern === 'string').slice(0, 100)
  103. : [],
  104. vitals: !!data.vitals,
  105. vitals_sample: toNumber(data.vitals_sample, 0, 100, 0),
  106. };
  107. configureHealth({
  108. errors: config.errors,
  109. ignore: config.errors_ignore,
  110. vitals: config.vitals,
  111. vitalsSample: config.vitals_sample,
  112. });
  113. try {
  114. visitorId = config.persistent ? await getVisitorId() : '';
  115. } catch {
  116. // storage blocked (web throws SecurityError): track without a visitor id
  117. visitorId = '';
  118. }
  119. // UNA requests carry it too, so sign-ups and other site actions join this device's visit
  120. setAnalyticsVisitorId(visitorId);
  121. state = 'on';
  122. } catch {
  123. // offline or timed out: keep the queued hits and try again later
  124. state = 'idle';
  125. configRetryAt = Date.now() + CONFIG_RETRY_MS;
  126. return;
  127. }
  128. // errors and vitals collected before the settings were known: only what the site takes
  129. const now = Date.now();
  130. const queued = pending
  131. .filter((item) => now - item.at < MAX_AGE_MS && acceptHealthHit(item.hit))
  132. .map(({ hit, at }) => (now - at > 0 ? { ...hit, age: (hit.age ?? 0) + now - at } : hit));
  133. pending = [];
  134. if (queued.length) void postInTurn(splitForCollect(queued));
  135. }
  136. /**
  137. * Hits in requests UNA takes whole: at most MAX_HITS each, and no more errors or vitals
  138. * than it reads from one request. The order stays.
  139. */
  140. function splitForCollect(hits: AnalyticsHit[]): AnalyticsHit[][] {
  141. const batches: AnalyticsHit[][] = [];
  142. let batch: AnalyticsHit[] = [];
  143. let counts: Partial<Record<AnalyticsHit['type'], number>> = {};
  144. for (const hit of hits) {
  145. const limit = MAX_PER_REQUEST[hit.type];
  146. const count = counts[hit.type] ?? 0;
  147. if (batch.length >= MAX_HITS || (limit !== undefined && count >= limit)) {
  148. batches.push(batch);
  149. batch = [];
  150. counts = {};
  151. }
  152. batch.push(hit);
  153. counts[hit.type] = (counts[hit.type] ?? 0) + 1;
  154. }
  155. if (batch.length) batches.push(batch);
  156. return batches;
  157. }
  158. /** Requests one after another: a later one's errors and vitals join the visit an earlier one started. */
  159. async function postInTurn(batches: AnalyticsHit[][]): Promise<void> {
  160. for (const batch of batches) {
  161. if (state === 'off') return;
  162. await post(batch, false);
  163. }
  164. }
  165. async function post(hits: AnalyticsHit[], keepalive: boolean): Promise<void> {
  166. const payload: AnalyticsPayload = { ...getClient(), hits: hits.slice(-MAX_HITS) };
  167. if (referrer) payload.referrer = referrer;
  168. if (visitorId) payload.vid = visitorId;
  169. // UNA reads the referrer for a new visit only: the first pageview's is enough
  170. if (hits.some((hit) => hit.type === 'pageview')) referrer = '';
  171. try {
  172. const response = await fetcher(
  173. [COLLECT_URL, null, JSON.stringify(payload)],
  174. false,
  175. { silent: true, maxAttempts: 1, timeoutMs: REQUEST_TIMEOUT_MS, keepalive },
  176. );
  177. if (response?.status !== 200) turnOff();
  178. } catch {
  179. // a lost hit: not worth a retry
  180. }
  181. }
  182. function submit(hits: AnalyticsHit[], keepalive = false): void {
  183. if (!hits.length || state === 'off' || !isClientAvailable()) return;
  184. if (state === 'on') {
  185. void post(hits, keepalive);
  186. return;
  187. }
  188. const now = Date.now();
  189. pending = pending.concat(hits.map((hit) => ({ hit, at: now }))).slice(-MAX_HITS);
  190. if (state === 'idle' && now >= configRetryAt) void loadConfig();
  191. }
  192. /** A UNA request failed on the server (a PHP fatal, a gateway error): an `http` error. */
  193. function onServerError(status: number, path: string): void {
  194. if (OWN_REQUEST.test(path)) return;
  195. const endpoint = cleanUrl(path);
  196. captureError('http', { message: `HTTP ${status} ${endpoint}`, source: endpoint, status });
  197. }
  198. // Errors flow into the queue as soon as this module runs in a browser or an app, even before
  199. // AnalyticsTracker mounts (web: a root layout that failed renders global-error.tsx instead).
  200. if (isClientAvailable()) {
  201. setHealthSink((hits, keepalive) => submit(hits, keepalive));
  202. setServerErrorListener(onServerError);
  203. }
  204. /** The visible time on the current page since the last leave hit, as a hit, or null. */
  205. function takeLeaveHit(): AnalyticsHit | null {
  206. const now = Date.now();
  207. const ms = engagedMs + (visibleSince ? now - visibleSince : 0);
  208. engagedMs = 0;
  209. visibleSince = visibleSince ? now : 0;
  210. return ms > 0 && currentUrl ? { type: 'leave', url: currentUrl, engaged: ms } : null;
  211. }
  212. /**
  213. * Start tracking: load the settings and start counting visible time. Called once by
  214. * `AnalyticsTracker` at the app root; later calls do nothing.
  215. */
  216. export function startTracker(visible: boolean): void {
  217. if (started || !isClientAvailable()) return;
  218. started = true;
  219. referrer = getInitialReferrer();
  220. visibleSince = visible ? Date.now() : 0;
  221. if (state === 'idle') void loadConfig();
  222. }
  223. /**
  224. * A page (web) or screen (apps) is shown. The visible time on the previous one is sent
  225. * with it. The same URL twice in a row counts once.
  226. */
  227. export function trackPageview(url: string, title = '', force = false): void {
  228. if (!url || (url === currentUrl && !force)) return;
  229. const hits: AnalyticsHit[] = [];
  230. const leave = takeLeaveHit();
  231. if (leave) hits.push(leave);
  232. if (currentUrl) {
  233. hits.push(...takeErrorHits(true));
  234. resetViewErrors();
  235. }
  236. currentUrl = url;
  237. currentTitle = title;
  238. hits.push(title ? { type: 'pageview', url, title } : { type: 'pageview', url });
  239. // after the pageview: vitals join a running visit
  240. const vitals = takeVitalsHit(false);
  241. if (vitals) hits.push(vitals);
  242. submit(hits);
  243. }
  244. /**
  245. * The page or app became visible or hidden (web: tab switch, page hide; apps: background).
  246. * Hiding sends the visible time. Coming back after a pause longer than UNA's visit timeout
  247. * shows the current page again, so the new visit has an entry page.
  248. */
  249. export function setTrackerVisible(visible: boolean): void {
  250. if (!started) return;
  251. const now = Date.now();
  252. if (!visible) {
  253. if (!visibleSince) return;
  254. const leave = takeLeaveHit();
  255. visibleSince = 0;
  256. hiddenAt = now;
  257. const hits = takeErrorHits(true);
  258. if (leave) hits.unshift(leave);
  259. const vitals = takeVitalsHit(true);
  260. if (vitals) hits.push(vitals);
  261. if (hits.length) submit(hits, true);
  262. // UNA takes one vitals hit per request
  263. for (let more = takeVitalsHit(false); more; more = takeVitalsHit(false)) submit([more], true);
  264. return;
  265. }
  266. if (visibleSince) return;
  267. visibleSince = now;
  268. const away = hiddenAt ? now - hiddenAt : 0;
  269. hiddenAt = 0;
  270. if (currentUrl && config && away > config.session_timeout * 1000) {
  271. engagedMs = 0;
  272. trackPageview(currentUrl, currentTitle, true);
  273. }
  274. }
  275. /**
  276. * Send a custom event to UNA's analytics, e.g. `trackEvent('signup_click', { plan: 'pro' })`.
  277. * Safe anywhere: does nothing while rendering on the server, when analytics is off, or
  278. * before the settings are known (then the event waits for them).
  279. *
  280. * @param name letters, digits, spaces and `_ . : / -`, up to 64 characters
  281. * @param props short scalar values (up to 30 keys)
  282. */
  283. export function trackEvent(name: string, props?: AnalyticsProps, options?: TrackEventOptions): void {
  284. if (!isClientAvailable()) return;
  285. const eventName = String(name ?? '').trim().slice(0, 64);
  286. if (!eventName) return;
  287. const hit: AnalyticsHit = { type: 'event', name: eventName, url: currentUrl || getFallbackUrl() };
  288. if (props && typeof props === 'object') {
  289. const scalars: AnalyticsProps = {};
  290. for (const [key, value] of Object.entries(props)) {
  291. if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') scalars[key] = value;
  292. }
  293. if (Object.keys(scalars).length) hit.props = scalars;
  294. }
  295. if (typeof options?.value === 'number' && Number.isFinite(options.value)) hit.value = options.value;
  296. if (options?.nonInteractive) hit.non_interactive = 1;
  297. submit([hit]);
  298. }
  299. /**
  300. * Report an error the app handled itself to UNA's analytics, e.g. `trackError(error)` in a
  301. * `catch`. Error boundaries pass `{ kind: 'react', fatal: true }`. Safe anywhere, like
  302. * `trackEvent`; sent only when the site tracks errors.
  303. */
  304. export function trackError(error: unknown, options?: TrackErrorOptions): void {
  305. captureError(options?.kind ?? 'js', {
  306. error,
  307. componentStack: options?.componentStack,
  308. fatal: options?.fatal,
  309. nonErrorPrefix: 'Non-Error exception: ',
  310. });
  311. }