@faststats/web 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -48,32 +48,48 @@ Consent remains client-local: use `"anonymous"` for cookieless collection,
48
48
 
49
49
  ### Visit duration
50
50
 
51
- `page_leave.properties.time_on_page` is the accumulated time in milliseconds
52
- that the page was visible and its document had focus. It pauses when the tab is
51
+ `properties.time_on_page` on `page_ping` and `page_leave` is the accumulated
52
+ time in milliseconds that the page was visible and its document had focus. It pauses when the tab is
53
53
  hidden, the window loses focus, or the page enters the back-forward cache, and
54
54
  resets on navigation, logout, or session rotation. A monotonic clock keeps
55
- system-clock changes from affecting this duration. Reading without interacting still counts while the page has focus;
56
- this metric does not infer whether a person is idle.
55
+ system-clock changes from affecting this duration. It also pauses after 60
56
+ seconds without pointer, keyboard, or scroll input, unless a `<video>` or
57
+ `<audio>` element on the page is playing, and resumes on the next input. Time
58
+ the device spent asleep or the tab frozen is not counted.
57
59
 
58
60
  Pageviews wait until the page becomes visible. Pages replaced or closed before
59
61
  being shown do not emit pageviews or page-leave events.
60
62
 
61
- The SDK sends cumulative `page_leave` checkpoints every 15 seconds while active
62
- and when activity pauses or the visit ends. Unchanged checkpoints are suppressed.
63
- Pageviews and their checkpoints share `properties.visit_id`; consumers should use
64
- the maximum `time_on_page` per visit, never sum its checkpoints. The existing
65
- session-duration and bounce-rate queries already use maximum values.
63
+ The SDK sends a cumulative `page_ping` when activity pauses and periodically
64
+ while active: after 15 and 45 seconds, then every 60 seconds. A ping that adds no
65
+ time is suppressed. A visit ends with
66
+ exactly one `page_leave`, on navigation, a terminal `pagehide`, logout, a consent
67
+ change, or session rotation. A page the browser discards without notice never
68
+ sends one, so its last ping holds its duration. A pageview, its pings, and its
69
+ leave share `properties.visit_id`; consumers should use the maximum
70
+ `time_on_page` per visit across both events, never sum them.
71
+
72
+ Before 0.13 the pings were sent as `page_leave` every 15 seconds. The collector
73
+ and dashboards read both shapes; deploy them before releasing this SDK.
74
+
75
+ ### Default context
76
+
77
+ Every event carries `timezone` (IANA name), `language`, and `screen`
78
+ (`1920x1080`, CSS pixels) beside the page context, separate from its properties.
79
+ Custom events and outbound links also carry the `visit_id` of the page view they
80
+ happened during.
66
81
 
67
82
  Each visit retains its original identity, session, and page metadata. A session
68
83
  or identity change starts a new visit, even on the same route. Both persistent
69
84
  and cookieless sessions expire after 30 minutes of inactivity or 24 hours of age.
70
85
 
71
- `session_duration` is elapsed wall-clock time since the session started, including
72
- time away. It is separate from active time on the page. Paused visits do not keep
73
- sending larger session durations while the user is away.
86
+ `session_duration` is elapsed wall-clock time from the session start to the last
87
+ counted activity. It includes time away between two active periods of the same
88
+ session, so it is separate from active time on the page. An idle visitor stops
89
+ extending the session, which then expires after 30 minutes like any other.
74
90
 
75
91
  Delivery is best effort: a sudden process kill or network failure can lose time
76
- since the last delivered checkpoint. The collector must accept successive events
92
+ since the last delivered ping. The collector must accept successive events
77
93
  for the same visitor and URL; deploy the removal of its five-second debounce
78
94
  before releasing this SDK.
79
95
 
@@ -1,4 +1,4 @@
1
- import { c as setStorageItem, o as getStorageItem, s as removeStorageItem, t as createId } from "./session-manager-CFQEWbgo.js";
1
+ import { c as setStorageItem, o as getStorageItem, s as removeStorageItem, t as createId } from "./session-manager-DUpF3mi3.js";
2
2
  //#region src/utils/identifiers.ts
3
3
  const ANONYMOUS_ID_KEY = "faststats_anon_id_";
4
4
  function getAnonymousId(siteKey, cookieless = false) {
@@ -27,18 +27,30 @@ function getReferrer() {
27
27
  const raw = document.referrer;
28
28
  if (!raw) return null;
29
29
  try {
30
- const pageOrigin = new URL(location.href).origin;
31
- if (new URL(raw, location.href).origin === pageOrigin) return null;
30
+ if (new URL(raw, location.href).origin === location.origin) return null;
32
31
  } catch {}
33
32
  return sanitizeUrl(raw);
34
33
  }
34
+ /** Visitor environment sent with every event; unavailable values are omitted. */
35
+ function getClientContext() {
36
+ let timezone;
37
+ try {
38
+ timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
39
+ } catch {}
40
+ const { width, height } = globalThis.screen ?? {};
41
+ return {
42
+ timezone,
43
+ language: globalThis.navigator?.language,
44
+ screen: width && height ? `${width}x${height}` : void 0
45
+ };
46
+ }
35
47
  function getPageContext(includeHash = false) {
36
48
  const hash = includeHash ? location.hash : "";
37
49
  const ctx = {
38
50
  page: `${location.pathname}${hash}`,
39
51
  url: sanitizeUrl(location.href, includeHash),
40
52
  referrer: getReferrer(),
41
- title: document.title || ""
53
+ title: document.title
42
54
  };
43
55
  if (!location.search) return ctx;
44
56
  const sp = new URLSearchParams(location.search);
@@ -53,8 +65,8 @@ function getPageContext(includeHash = false) {
53
65
  async function sendData(options) {
54
66
  const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
55
67
  if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
56
- const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
57
- if (globalThis.navigator.sendBeacon(url, beaconBody)) {
68
+ const body = data instanceof Blob ? data : new Blob([data], { type: contentType });
69
+ if (globalThis.navigator.sendBeacon(url, body)) {
58
70
  if (debug) console.log(`${debugPrefix} Sent via beacon`);
59
71
  return true;
60
72
  }
@@ -81,4 +93,4 @@ async function sendData(options) {
81
93
  }
82
94
  }
83
95
  //#endregion
84
- export { sanitizeUrl as a, resolveBaseUrl as i, URLS as n, getPageContext as r, sendData as t };
96
+ export { resolveBaseUrl as a, getPageContext as i, URLS as n, sanitizeUrl as o, getClientContext as r, sendData as t };
@@ -137,15 +137,12 @@ function resetSession(siteKey, cookieless = false) {
137
137
  }
138
138
  function onSessionRotated(siteKey, cookieless, listener) {
139
139
  const key = memoryKey(siteKey, cookieless);
140
- let listeners = rotationListeners.get(key);
141
- if (!listeners) {
142
- listeners = /* @__PURE__ */ new Set();
143
- rotationListeners.set(key, listeners);
144
- }
140
+ const listeners = rotationListeners.get(key) ?? /* @__PURE__ */ new Set();
141
+ rotationListeners.set(key, listeners);
145
142
  listeners.add(listener);
146
143
  return () => {
147
- listeners?.delete(listener);
148
- if (listeners?.size === 0) rotationListeners.delete(key);
144
+ listeners.delete(listener);
145
+ if (listeners.size === 0) rotationListeners.delete(key);
149
146
  };
150
147
  }
151
148
  //#endregion
package/dist/error.js CHANGED
@@ -1,9 +1,9 @@
1
- import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as sendData } from "./chunks/send-data-DYZZjeM-.js";
2
- import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
1
+ import { a as resolveBaseUrl, i as getPageContext, n as URLS, t as sendData } from "./chunks/send-data-C76HbZ8d.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.12.0";
6
+ const SDK_VERSION = "0.13.0";
7
7
  function errorTracking(options = {}) {
8
8
  return {
9
9
  name: "error-tracking",
package/dist/flags.js CHANGED
@@ -1,5 +1,5 @@
1
- import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.js";
2
- import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
1
+ import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
3
3
  //#region src/flags.ts
4
4
  const DEFAULT_ENDPOINT = "https://flags.faststats.dev";
5
5
  const DEFAULT_TIMEOUT_MS = 3e3;
@@ -138,7 +138,7 @@ var FeatureFlags = class {
138
138
  }
139
139
  }
140
140
  };
141
- function featureFlags(options = {}) {
141
+ function featureFlags(options) {
142
142
  return new FeatureFlags(options);
143
143
  }
144
144
  //#endregion
package/dist/index.d.ts CHANGED
@@ -38,6 +38,7 @@ export declare class WebAnalytics {
38
38
  private pageKey;
39
39
  private visit;
40
40
  private pendingPageview;
41
+ private lastInputAt;
41
42
  private consentMode;
42
43
  constructor(options: WebAnalyticsOptions);
43
44
  start(): void;
@@ -65,7 +66,13 @@ export declare class WebAnalytics {
65
66
  private readonly onPageHide;
66
67
  private readonly onPageShow;
67
68
  private readonly onResume;
69
+ private readonly onActivity;
70
+ private isIdle;
68
71
  private leavePage;
72
+ /**
73
+ * Reports the visit's running totals: as a `page_ping` while it may still
74
+ * continue, and as its single `page_leave` once it has ended.
75
+ */
69
76
  private checkpoint;
70
77
  private pauseVisit;
71
78
  private resumeVisit;
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, r as getPageContext, t as sendData } from "./chunks/send-data-DYZZjeM-.js";
2
- import { a as touchActivity, i as resetSession, n as getSessionContext, t as createId } from "./chunks/session-manager-CFQEWbgo.js";
3
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
1
+ import { a as resolveBaseUrl, i as getPageContext, n as URLS, o as sanitizeUrl, r as getClientContext, t as sendData } from "./chunks/send-data-C76HbZ8d.js";
2
+ import { a as touchActivity, i as resetSession, n as getSessionContext, t as createId } from "./chunks/session-manager-DUpF3mi3.js";
3
+ import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
4
4
  //#region src/extensions.ts
5
5
  var ExtensionHost = class {
6
6
  context;
@@ -40,11 +40,8 @@ var ExtensionHost = class {
40
40
  ...this.context,
41
41
  on: (hook, handler) => {
42
42
  if (this.stopped) return () => {};
43
- let handlers = this.handlers.get(hook);
44
- if (!handlers) {
45
- handlers = /* @__PURE__ */ new Set();
46
- this.handlers.set(hook, handlers);
47
- }
43
+ const handlers = this.handlers.get(hook) ?? /* @__PURE__ */ new Set();
44
+ this.handlers.set(hook, handlers);
48
45
  handlers.add(handler);
49
46
  const off = () => handlers.delete(handler);
50
47
  subscriptions.push(off);
@@ -128,10 +125,12 @@ function observeHistory(listener) {
128
125
  }
129
126
  const RESERVED_EVENTS = /* @__PURE__ */ new Set([
130
127
  "pageview",
128
+ "page_ping",
131
129
  "page_leave",
132
130
  "error",
133
131
  "outbound_link"
134
132
  ]);
133
+ const IDLE_MS = 6e4;
135
134
  var WebAnalytics = class {
136
135
  options;
137
136
  baseUrl;
@@ -143,6 +142,7 @@ var WebAnalytics = class {
143
142
  pageKey = "";
144
143
  visit = null;
145
144
  pendingPageview = null;
145
+ lastInputAt = 0;
146
146
  consentMode;
147
147
  constructor(options) {
148
148
  this.options = options;
@@ -170,7 +170,7 @@ var WebAnalytics = class {
170
170
  const key = location.pathname + location.search + (this.options.trackHash ? location.hash : "");
171
171
  if (key !== this.pageKey) {
172
172
  this.leavePage();
173
- if (this.pageKey) this.extensions?.emit("pageChange", { url: getPageContext(this.options.trackHash).url ?? "" });
173
+ if (this.pageKey) this.extensions?.emit("pageChange", { url: getPageContext(this.options.trackHash).url });
174
174
  this.pageKey = key;
175
175
  this.pendingPageview = null;
176
176
  }
@@ -190,7 +190,10 @@ var WebAnalytics = class {
190
190
  context,
191
191
  duration: 0,
192
192
  startedAt: null,
193
- reportedDuration: -1
193
+ checkedAt: Date.now(),
194
+ activeAt: Date.now(),
195
+ reportedDuration: -1,
196
+ beats: 0
194
197
  };
195
198
  this.send("pageview", {
196
199
  ...properties,
@@ -291,6 +294,7 @@ var WebAnalytics = class {
291
294
  }
292
295
  context(session = this.session()) {
293
296
  return {
297
+ ...getClientContext(),
294
298
  ...getPageContext(this.options.trackHash),
295
299
  userId: this.getAnonymousId() || void 0,
296
300
  sessionId: session.sessionId,
@@ -326,12 +330,18 @@ var WebAnalytics = class {
326
330
  this.bind(window, "focus", this.onResume);
327
331
  this.bind(window, "blur", () => this.pauseVisit());
328
332
  this.bind(window, "popstate", this.onNavigate);
333
+ for (const type of [
334
+ "pointerdown",
335
+ "pointermove",
336
+ "keydown",
337
+ "scroll"
338
+ ]) this.bind(window, type, this.onActivity, true);
329
339
  if (this.options.trackHash) this.bind(window, "hashchange", this.onNavigate);
330
340
  this.cleanup.push(observeHistory(this.onNavigate), trackOutboundLinks((url) => this.send("outbound_link", { outbound_link: url })));
331
341
  }
332
- bind(target, type, listener) {
333
- target.addEventListener(type, listener);
334
- this.cleanup.push(() => target.removeEventListener(type, listener));
342
+ bind(target, type, listener, capture = false) {
343
+ target.addEventListener(type, listener, capture);
344
+ this.cleanup.push(() => target.removeEventListener(type, listener, capture));
335
345
  }
336
346
  onNavigate = () => this.pageview({ trigger: "navigation" });
337
347
  onVisibilityChange = () => {
@@ -349,12 +359,12 @@ var WebAnalytics = class {
349
359
  onPageHide = (event) => {
350
360
  if (!this.canTrack()) return;
351
361
  const persisted = event.persisted;
352
- this.pauseVisit();
362
+ if (persisted) this.pauseVisit();
363
+ else this.leavePage();
353
364
  this.extensions?.emit("pageHide", {
354
365
  persisted,
355
366
  terminal: !persisted
356
367
  });
357
- if (!persisted) this.leavePage();
358
368
  };
359
369
  onPageShow = (event) => {
360
370
  if (!this.canTrack()) return;
@@ -365,49 +375,74 @@ var WebAnalytics = class {
365
375
  this.pageview(this.pendingPageview ?? { trigger: "session" });
366
376
  this.resumeVisit();
367
377
  };
378
+ onActivity = () => {
379
+ this.lastInputAt = performance.now();
380
+ if (this.visit?.startedAt === null && document.hasFocus()) this.onResume();
381
+ };
382
+ isIdle() {
383
+ const now = performance.now();
384
+ if (now - this.lastInputAt < IDLE_MS) return false;
385
+ const playing = [...document.querySelectorAll("video,audio")].some((media) => !media.paused && !media.ended);
386
+ if (playing) this.lastInputAt = now;
387
+ return !playing;
388
+ }
368
389
  leavePage() {
369
- this.pauseVisit();
390
+ this.pauseVisit(true);
370
391
  this.visit = null;
371
392
  }
372
- checkpoint() {
393
+ /**
394
+ * Reports the visit's running totals: as a `page_ping` while it may still
395
+ * continue, and as its single `page_leave` once it has ended.
396
+ */
397
+ checkpoint(leaving = false, periodic = false) {
373
398
  const visit = this.visit;
374
399
  if (!visit || !this.isActive()) return;
375
400
  if (visit.startedAt !== null) {
376
401
  const now = performance.now();
377
- visit.duration += now - visit.startedAt;
402
+ if (now - visit.startedAt < IDLE_MS) {
403
+ const active = Math.min(now, this.lastInputAt + IDLE_MS) - visit.startedAt;
404
+ visit.duration += active;
405
+ visit.activeAt = visit.checkedAt + active;
406
+ }
378
407
  visit.startedAt = now;
408
+ visit.checkedAt = Date.now();
379
409
  }
410
+ if (periodic && ++visit.beats !== 1 && visit.beats % 4 !== 3) return;
380
411
  const duration = Math.round(visit.duration);
381
- if (duration === visit.reportedDuration) return;
412
+ if (!leaving && duration === visit.reportedDuration) return;
382
413
  visit.reportedDuration = duration;
383
- this.send("page_leave", {
414
+ this.send(leaving ? "page_leave" : "page_ping", {
384
415
  visit_id: visit.id,
385
416
  time_on_page: duration,
386
- session_duration: Math.max(0, Date.now() - visit.session.sessionStart)
417
+ session_duration: Math.max(0, visit.activeAt - visit.session.sessionStart)
387
418
  }, visit.context);
388
419
  }
389
- pauseVisit() {
420
+ pauseVisit(leaving = false) {
390
421
  if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
391
422
  this.heartbeatTimer = null;
392
- this.checkpoint();
423
+ this.checkpoint(leaving);
393
424
  if (this.visit) this.visit.startedAt = null;
394
425
  }
395
426
  resumeVisit() {
396
427
  if (!this.isActive() || !this.visit || this.visit.startedAt !== null || document.visibilityState !== "visible" || !document.hasFocus()) return;
397
- this.visit.startedAt = performance.now();
428
+ this.visit.startedAt = this.lastInputAt = performance.now();
429
+ this.visit.checkedAt = Date.now();
398
430
  this.heartbeatTimer = setInterval(() => {
399
- if (document.visibilityState !== "visible" || !document.hasFocus()) {
431
+ if (document.visibilityState !== "visible" || !document.hasFocus() || this.isIdle()) {
400
432
  this.pauseVisit();
401
433
  return;
402
434
  }
403
435
  const visit = this.visit;
404
436
  this.pageview({ trigger: "session" });
405
- if (this.visit === visit) this.checkpoint();
437
+ if (this.visit === visit) this.checkpoint(false, true);
406
438
  }, 15e3);
407
439
  }
408
440
  send(event, properties = {}, context) {
409
441
  if (!this.isActive()) return;
410
- context ??= this.context();
442
+ context ??= {
443
+ ...this.context(),
444
+ visit_id: this.visit?.id
445
+ };
411
446
  let data;
412
447
  try {
413
448
  data = JSON.stringify({
package/dist/replay.d.ts CHANGED
@@ -133,4 +133,5 @@ export default class ReplayTracker {
133
133
  private retryDelay;
134
134
  private sendBatch;
135
135
  }
136
- //#endregion
136
+ //#endregion
137
+ export {};
package/dist/replay.js CHANGED
@@ -1,6 +1,6 @@
1
- import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as sendData } from "./chunks/send-data-DYZZjeM-.js";
2
- import { a as touchActivity, c as setStorageItem, n as getSessionContext, o as getStorageItem, r as onSessionRotated, t as createId } from "./chunks/session-manager-CFQEWbgo.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
1
+ import { a as resolveBaseUrl, n as URLS, o as sanitizeUrl, t as sendData } from "./chunks/send-data-C76HbZ8d.js";
2
+ import { a as touchActivity, c as setStorageItem, n as getSessionContext, o as getStorageItem, r as onSessionRotated, t as createId } from "./chunks/session-manager-DUpF3mi3.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
4
4
  import { record } from "@rrweb/record";
5
5
  import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
6
6
  import { getRecordNetworkPlugin } from "@rrweb/rrweb-plugin-network-record";
@@ -26,7 +26,7 @@ var ReplayMutationThrottler = class {
26
26
  if (data.source !== MUTATION_SOURCE_TYPE || !data.attributes?.length) return event;
27
27
  const attributes = data.attributes.filter(({ id }) => this.consume(id));
28
28
  if (attributes.length === data.attributes.length) return event;
29
- if (attributes.length === 0 && (data.adds?.length ?? 0) === 0 && (data.removes?.length ?? 0) === 0 && (data.texts?.length ?? 0) === 0) return;
29
+ if (!attributes.length && !data.adds?.length && !data.removes?.length && !data.texts?.length) return;
30
30
  return {
31
31
  ...event,
32
32
  data: {
@@ -482,12 +482,11 @@ var ReplayTracker = class {
482
482
  return;
483
483
  }
484
484
  if (this.minLengthFlushTask) return;
485
- const remainingMs = Math.max(0, this.minReplayLengthMs - (Date.now() - this.startTime));
486
485
  this.minLengthFlushTask = setTimeout(() => {
487
486
  this.minLengthFlushTask = null;
488
487
  this.initialFlushPending = false;
489
488
  this.requestFlush("minLength");
490
- }, Math.min(remainingMs, MAX_TIMEOUT_MS));
489
+ }, this.minReplayLengthMs - (Date.now() - this.startTime));
491
490
  }
492
491
  enqueueEventsIfReady(context, flushReason, maxBatchSizeBytes = this.maxBatchSizeBytes) {
493
492
  if (this.events.length === 0) return;
@@ -16,7 +16,8 @@ export default class WebVitalsTracker {
16
16
  private sending?;
17
17
  private generation;
18
18
  private unsubscribe?;
19
- private readonly sentMetrics;
19
+ private unfollowSession?;
20
+ private sentMetrics;
20
21
  private initialUrl;
21
22
  private session;
22
23
  private cookieless;
@@ -25,6 +26,7 @@ export default class WebVitalsTracker {
25
26
  start(): void;
26
27
  stop(discard?: boolean): void;
27
28
  onPageHidden(persisted?: boolean): void;
29
+ private followSession;
28
30
  private onMetric;
29
31
  private discard;
30
32
  private flush;
@@ -1,36 +1,49 @@
1
- import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as sendData } from "./chunks/send-data-DYZZjeM-.js";
2
- import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.js";
1
+ import { a as resolveBaseUrl, n as URLS, o as sanitizeUrl, t as sendData } from "./chunks/send-data-C76HbZ8d.js";
2
+ import { n as getSessionContext, r as onSessionRotated } from "./chunks/session-manager-DUpF3mi3.js";
3
3
  import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals/attribution";
4
4
  //#region src/utils/web-vitals-collector.ts
5
5
  const collectors = /* @__PURE__ */ new WeakMap();
6
+ function getCollector() {
7
+ const existing = collectors.get(document);
8
+ if (existing) return existing;
9
+ const collector = {
10
+ subscribers: /* @__PURE__ */ new Set(),
11
+ latest: /* @__PURE__ */ new Map(),
12
+ sent: /* @__PURE__ */ new Map()
13
+ };
14
+ collectors.set(document, collector);
15
+ const report = (metric) => {
16
+ const snapshot = { ...metric };
17
+ snapshot.attribution = { ...metric.attribution };
18
+ collector.latest.set(metric.name, snapshot);
19
+ for (const listener of collector.subscribers) listener(snapshot);
20
+ };
21
+ const changes = { reportAllChanges: true };
22
+ onCLS(report, changes);
23
+ onINP(report, changes);
24
+ onLCP(report, changes);
25
+ onFCP(report);
26
+ onTTFB(report);
27
+ return collector;
28
+ }
6
29
  function subscribeToVitals(subscriber) {
7
- let collector = collectors.get(document);
8
- if (!collector) {
9
- collector = {
10
- subscribers: /* @__PURE__ */ new Set(),
11
- latest: /* @__PURE__ */ new Map()
12
- };
13
- collectors.set(document, collector);
14
- const { latest, subscribers } = collector;
15
- const report = (metric) => {
16
- const snapshot = { ...metric };
17
- snapshot.attribution = { ...metric.attribution };
18
- latest.set(metric.name, snapshot);
19
- for (const listener of subscribers) listener(snapshot);
20
- };
21
- const changes = { reportAllChanges: true };
22
- onCLS(report, changes);
23
- onINP(report, changes);
24
- onLCP(report, changes);
25
- onFCP(report);
26
- onTTFB(report);
27
- }
28
- collector.subscribers.add(subscriber);
29
- for (const metric of collector.latest.values()) subscriber(metric);
30
+ const { subscribers, latest } = getCollector();
31
+ subscribers.add(subscriber);
32
+ for (const metric of latest.values()) subscriber(metric);
30
33
  return () => {
31
- collector.subscribers.delete(subscriber);
34
+ subscribers.delete(subscriber);
32
35
  };
33
36
  }
37
+ /**
38
+ * What `destination` already received from this document. Shared so a
39
+ * re-created tracker does not resend the metrics replayed to it.
40
+ */
41
+ function sentVitals(destination) {
42
+ const { sent } = getCollector();
43
+ const vitals = sent.get(destination) ?? /* @__PURE__ */ new Map();
44
+ sent.set(destination, vitals);
45
+ return vitals;
46
+ }
34
47
  //#endregion
35
48
  //#region src/web-vitals.ts
36
49
  function webVitals() {
@@ -51,13 +64,6 @@ function webVitals() {
51
64
  }
52
65
  };
53
66
  }
54
- const METRIC_NAMES = /* @__PURE__ */ new Set([
55
- "CLS",
56
- "INP",
57
- "LCP",
58
- "FCP",
59
- "TTFB"
60
- ]);
61
67
  var WebVitalsTracker = class {
62
68
  options;
63
69
  endpoint;
@@ -66,6 +72,7 @@ var WebVitalsTracker = class {
66
72
  sending;
67
73
  generation = 0;
68
74
  unsubscribe;
75
+ unfollowSession;
69
76
  sentMetrics = /* @__PURE__ */ new Map();
70
77
  initialUrl = "";
71
78
  session = null;
@@ -78,21 +85,21 @@ var WebVitalsTracker = class {
78
85
  setCookielessMode(cookieless) {
79
86
  if (this.cookieless === cookieless) return;
80
87
  this.cookieless = cookieless;
81
- this.discard();
82
- this.session = this.started ? getSessionContext(this.options.siteKey, cookieless) : null;
88
+ if (this.started) this.followSession();
83
89
  }
84
90
  start() {
85
91
  if (this.started || typeof window === "undefined") return;
86
92
  this.started = true;
87
93
  this.initialUrl = sanitizeUrl(window.performance?.getEntriesByType("navigation")[0]?.name || window.location.href, this.options.trackHash ?? false);
88
- this.session = getSessionContext(this.options.siteKey, this.cookieless);
94
+ this.followSession();
95
+ this.sentMetrics = sentVitals(`${this.endpoint}\0${this.options.siteKey}`);
89
96
  this.unsubscribe = subscribeToVitals(this.onMetric);
90
97
  }
91
98
  stop(discard = false) {
92
99
  if (!this.started) return;
93
100
  this.started = false;
94
101
  this.unsubscribe?.();
95
- this.unsubscribe = void 0;
102
+ this.unfollowSession?.();
96
103
  if (discard) this.discard();
97
104
  else this.flush();
98
105
  }
@@ -103,26 +110,39 @@ var WebVitalsTracker = class {
103
110
  if (generation === this.generation) this.flush();
104
111
  });
105
112
  }
113
+ followSession() {
114
+ const { siteKey } = this.options;
115
+ this.unfollowSession?.();
116
+ this.session = getSessionContext(siteKey, this.cookieless);
117
+ this.unfollowSession = onSessionRotated(siteKey, this.cookieless, (_prev, next) => {
118
+ this.session = next;
119
+ });
120
+ }
106
121
  onMetric = (metric) => {
107
- if (!this.started || !METRIC_NAMES.has(metric.name) || !Number.isFinite(metric.value) || metric.value < 0) return;
122
+ if (!this.started || !Number.isFinite(metric.value) || metric.value < 0) return;
108
123
  this.metrics.set(metric.name, metric);
109
124
  };
110
125
  discard() {
111
126
  this.generation++;
112
127
  this.metrics.clear();
113
- this.sentMetrics.clear();
114
128
  }
115
129
  async flush() {
116
130
  const generation = this.generation;
117
131
  while (this.sending) await this.sending;
118
- if (generation !== this.generation || !this.metrics.size) return;
119
- const batch = this.metrics;
120
- const session = this.session ?? getSessionContext(this.options.siteKey, this.cookieless);
132
+ const session = this.session;
133
+ if (generation !== this.generation || !session) return;
134
+ const batch = [...this.metrics.values()].filter(({ name, id, value }) => {
135
+ const sent = this.sentMetrics.get(name);
136
+ return sent?.id !== id || sent.value !== value;
137
+ });
138
+ this.metrics = /* @__PURE__ */ new Map();
139
+ if (!batch.length) return;
140
+ const trackHash = this.options.trackHash ?? false;
121
141
  const data = JSON.stringify({
122
142
  token: this.options.siteKey,
123
143
  sessionId: session.sessionId,
124
144
  windowId: session.windowId,
125
- vitals: [...batch.values()].map(({ name, value, id, rating, navigationType, attribution }) => {
145
+ vitals: batch.map(({ name, value, id, rating, navigationType, attribution }) => {
126
146
  const previous = this.sentMetrics.get(name);
127
147
  return {
128
148
  metric: name,
@@ -137,8 +157,7 @@ var WebVitalsTracker = class {
137
157
  };
138
158
  }),
139
159
  metadata: { url: this.initialUrl }
140
- });
141
- this.metrics = /* @__PURE__ */ new Map();
160
+ }, (_key, value) => typeof value === "string" && /^https?:\/\//.test(value) ? sanitizeUrl(value, trackHash) : value);
142
161
  this.sending = sendData({
143
162
  url: this.endpoint,
144
163
  data,
@@ -147,12 +166,8 @@ var WebVitalsTracker = class {
147
166
  });
148
167
  const sent = await this.sending;
149
168
  this.sending = void 0;
150
- if (generation !== this.generation) return;
151
- for (const [name, vital] of batch) if (sent) this.sentMetrics.set(name, {
152
- id: vital.id,
153
- value: vital.value
154
- });
155
- else if (!this.metrics.has(name)) this.metrics.set(name, vital);
169
+ for (const vital of batch) if (sent) this.sentMetrics.set(vital.name, vital);
170
+ else if (generation === this.generation && !this.metrics.has(vital.name)) this.metrics.set(vital.name, vital);
156
171
  }
157
172
  };
158
173
  //#endregion
package/package.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "version": "0.12.0",
45
+ "version": "0.13.0",
46
46
  "scripts": {
47
47
  "build": "tsdown && bun run check-size",
48
48
  "dev": "bun run build",