@faststats/web 0.8.1 → 0.9.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
@@ -19,7 +19,7 @@ init({
19
19
  extensions: [
20
20
  outboundLinks(),
21
21
  errorTracking(),
22
- webVitals({ attribution: true }),
22
+ webVitals(),
23
23
  sessionReplay({ recordConsole: true }),
24
24
  ],
25
25
  });
@@ -47,6 +47,37 @@ analytics.destroy();
47
47
  Consent remains client-local: use `"anonymous"` for cookieless collection,
48
48
  `"granted"` for normal collection, or `"denied"` to keep the client dormant.
49
49
 
50
+ ### Visit duration
51
+
52
+ `page_leave.properties.time_on_page` is the accumulated time in milliseconds
53
+ that the page was visible and its document had focus. It pauses when the tab is
54
+ hidden, the window loses focus, or the page enters the back-forward cache, and
55
+ resets on navigation, logout, or session rotation. A monotonic clock keeps
56
+ system-clock changes from affecting this duration. Reading without interacting still counts while the page has focus;
57
+ this metric does not infer whether a person is idle.
58
+
59
+ Pageviews wait until the page becomes visible. Pages replaced or closed before
60
+ being shown do not emit pageviews or page-leave events.
61
+
62
+ The SDK sends cumulative `page_leave` checkpoints every 15 seconds while active
63
+ and when activity pauses or the visit ends. Unchanged checkpoints are suppressed.
64
+ Pageviews and their checkpoints share `properties.visit_id`; consumers should use
65
+ the maximum `time_on_page` per visit, never sum its checkpoints. The existing
66
+ session-duration and bounce-rate queries already use maximum values.
67
+
68
+ Each visit retains its original identity, session, and page metadata. A session
69
+ or identity change starts a new visit, even on the same route. Both persistent
70
+ and cookieless sessions expire after 30 minutes of inactivity or 24 hours of age.
71
+
72
+ `session_duration` is elapsed wall-clock time since the session started, including
73
+ time away. It is separate from active time on the page. Paused visits do not keep
74
+ sending larger session durations while the user is away.
75
+
76
+ Delivery is best effort: a sudden process kill or network failure can lose time
77
+ since the last delivered checkpoint. The collector must accept successive events
78
+ for the same visitor and URL; deploy the removal of its five-second debounce
79
+ before releasing this SDK.
80
+
50
81
  ### Custom extensions
51
82
 
52
83
  An extension registers only the lifecycle hooks it needs. Hook failures are
@@ -1,4 +1,4 @@
1
- import { c as removeStorageItem, l as setStorageItem, n as createId, s as getStorageItem } from "./send-data-DFZnsaL2.js";
1
+ import { c as removeStorageItem, l as setStorageItem, n as createId, s as getStorageItem } from "./send-data-Dplz7m4c.js";
2
2
  //#region src/utils/identifiers.ts
3
3
  const ANONYMOUS_ID_KEY = "faststats_anon_id_";
4
4
  function getOrCreateAnonymousId(siteKey) {
@@ -64,17 +64,13 @@ function readSession(siteKey) {
64
64
  if (!raw) return memory;
65
65
  try {
66
66
  const session = JSON.parse(raw);
67
- return session.id ? session : memory;
67
+ return typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity) ? session : memory;
68
68
  } catch {}
69
69
  return memory;
70
70
  }
71
71
  function writeSession(siteKey, session) {
72
72
  if (!setStorageItem("localStorage", `${SESSION_KEY}${siteKey}`, JSON.stringify(session))) writeMemory(siteKey, false, session);
73
73
  }
74
- function clearSession(siteKey, cookieless) {
75
- memorySessions.delete(memoryKey(siteKey, cookieless));
76
- if (!cookieless) removeStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
77
- }
78
74
  function expired(session, now) {
79
75
  return now - session.activity >= IDLE_MS || now - session.start >= MAX_AGE_MS;
80
76
  }
@@ -83,41 +79,27 @@ function emitRotation(siteKey, cookieless, prev, next) {
83
79
  }
84
80
  function resolveSession(siteKey, cookieless, touch) {
85
81
  const now = Date.now();
86
- if (cookieless) {
87
- let session = readMemory(siteKey, true) ?? void 0;
88
- if (!session) {
89
- session = {
90
- id: createId(),
91
- start: now,
92
- activity: now
93
- };
94
- writeMemory(siteKey, true, session);
95
- } else if (touch) session.activity = now;
96
- return {
97
- session,
98
- rotatedFrom: null
99
- };
100
- }
101
- const existing = readSession(siteKey);
102
- if (existing && !expired(existing, now)) {
103
- if (touch) {
104
- existing.activity = now;
105
- writeSession(siteKey, existing);
106
- }
107
- return {
108
- session: existing,
109
- rotatedFrom: null
110
- };
111
- }
112
- const session = {
82
+ const existing = cookieless ? readMemory(siteKey, true) : readSession(siteKey);
83
+ const rotatedFrom = existing && expired(existing, now) ? existing : null;
84
+ const session = existing && !rotatedFrom ? existing : {
113
85
  id: createId(),
114
86
  start: now,
115
87
  activity: now
116
88
  };
117
- writeSession(siteKey, session);
89
+ if (touch) session.activity = now;
90
+ if (touch || session !== existing) {
91
+ if (cookieless) writeMemory(siteKey, true, session);
92
+ else writeSession(siteKey, session);
93
+ }
94
+ const context = toContext(siteKey, session, cookieless);
95
+ const rotation = rotatedFrom ? {
96
+ prev: toContext(siteKey, rotatedFrom, cookieless),
97
+ next: context
98
+ } : null;
99
+ if (rotation) emitRotation(siteKey, cookieless, rotation.prev, rotation.next);
118
100
  return {
119
- session,
120
- rotatedFrom: existing
101
+ context,
102
+ rotation
121
103
  };
122
104
  }
123
105
  function windowId(siteKey, cookieless, sessionId) {
@@ -128,41 +110,26 @@ function windowId(siteKey, cookieless, sessionId) {
128
110
  const id = createId();
129
111
  return setStorageItem("sessionStorage", key, id) ? id : sessionId;
130
112
  }
131
- function toContext(siteKey, session, cookieless, windowIdOverride) {
113
+ function toContext(siteKey, session, cookieless) {
132
114
  return {
133
115
  sessionId: session.id,
134
- windowId: windowIdOverride ?? windowId(siteKey, cookieless, session.id),
116
+ windowId: windowId(siteKey, cookieless, session.id),
135
117
  sessionStart: session.start
136
118
  };
137
119
  }
138
120
  function getSessionContext(siteKey, cookieless = false) {
139
- const { session, rotatedFrom } = resolveSession(siteKey, cookieless, false);
140
- const next = toContext(siteKey, session, cookieless);
141
- if (rotatedFrom) emitRotation(siteKey, cookieless, toContext(siteKey, rotatedFrom, cookieless, next.windowId), next);
142
- return next;
121
+ return resolveSession(siteKey, cookieless, false).context;
143
122
  }
144
123
  function touchActivity(siteKey, cookieless = false) {
145
- const { session, rotatedFrom } = resolveSession(siteKey, cookieless, true);
146
- if (!rotatedFrom) return null;
147
- const wid = windowId(siteKey, cookieless, session.id);
148
- const rotation = {
149
- prev: toContext(siteKey, rotatedFrom, cookieless, wid),
150
- next: toContext(siteKey, session, cookieless, wid)
151
- };
152
- emitRotation(siteKey, cookieless, rotation.prev, rotation.next);
153
- return rotation;
124
+ return resolveSession(siteKey, cookieless, true).rotation;
154
125
  }
155
126
  function resetSession(siteKey, cookieless = false) {
156
127
  const previous = getSessionContext(siteKey, cookieless);
157
- clearSession(siteKey, cookieless);
158
- if (cookieless) {
159
- const now = Date.now();
160
- writeMemory(siteKey, true, {
161
- id: createId(),
162
- start: now,
163
- activity: now
164
- });
165
- } else removeStorageItem("sessionStorage", `faststats_window_id_${siteKey}`);
128
+ memorySessions.delete(memoryKey(siteKey, cookieless));
129
+ if (!cookieless) {
130
+ removeStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
131
+ removeStorageItem("sessionStorage", `faststats_window_id_${siteKey}`);
132
+ }
166
133
  const next = getSessionContext(siteKey, cookieless);
167
134
  if (previous.sessionId !== next.sessionId) emitRotation(siteKey, cookieless, previous, next);
168
135
  return next;
package/dist/error.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
2
2
  //#region src/error.d.ts
3
- type ErrorEntry = {
3
+ export type ErrorEntry = {
4
4
  error: string;
5
5
  message?: string;
6
6
  stack?: string[];
7
7
  handled?: boolean;
8
8
  };
9
- interface ErrorTrackingOptions {
9
+ export interface ErrorTrackingOptions {
10
10
  siteKey: string;
11
11
  baseUrl?: string;
12
12
  debug?: boolean;
@@ -17,9 +17,9 @@ interface ErrorTrackingOptions {
17
17
  sdkName?: string;
18
18
  sdkVersion?: string;
19
19
  }
20
- type ErrorTrackingExtensionOptions = Omit<ErrorTrackingOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless" | "sdkName" | "sdkVersion">;
21
- declare function errorTracking(options?: ErrorTrackingExtensionOptions): AnalyticsExtension;
22
- declare class ErrorTracker {
20
+ export type ErrorTrackingExtensionOptions = Omit<ErrorTrackingOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless" | "sdkName" | "sdkVersion">;
21
+ export declare function errorTracking(options?: ErrorTrackingExtensionOptions): AnalyticsExtension;
22
+ export default class ErrorTracker {
23
23
  private readonly options;
24
24
  private readonly endpoint;
25
25
  private queue;
@@ -38,5 +38,4 @@ declare class ErrorTracker {
38
38
  private enqueue;
39
39
  private flush;
40
40
  }
41
- //#endregion
42
- export { ErrorEntry, ErrorTrackingExtensionOptions, ErrorTrackingOptions, ErrorTracker as default, errorTracking };
41
+ //#endregion
package/dist/error.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { r as getSessionContext, t as sendData } from "./chunks/send-data-DFZnsaL2.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-KgQDX74Q.js";
2
+ import { r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-xX7o7oZz.js";
4
4
  //#region src/error.ts
5
5
  const SDK_NAME = "@faststats/web";
6
- const SDK_VERSION = "0.8.1";
6
+ const SDK_VERSION = "0.9.0";
7
7
  function errorTracking(options = {}) {
8
8
  return {
9
9
  name: "error-tracking",
package/dist/index.d.ts CHANGED
@@ -27,7 +27,7 @@ interface WebAnalyticsOptions {
27
27
  /** @internal */
28
28
  sdkVersion?: string;
29
29
  }
30
- declare class WebAnalytics {
30
+ export declare class WebAnalytics {
31
31
  private readonly options;
32
32
  private readonly baseUrl;
33
33
  private readonly debug;
@@ -36,11 +36,8 @@ declare class WebAnalytics {
36
36
  private heartbeatTimer;
37
37
  private extensions;
38
38
  private pageKey;
39
- private entry;
40
- private left;
41
- private visitDuration;
42
- private visitStartedAt;
43
- private pendingPageviewTrigger;
39
+ private visit;
40
+ private pendingPageview;
44
41
  private consentMode;
45
42
  constructor(options: WebAnalyticsOptions);
46
43
  start(): void;
@@ -55,7 +52,7 @@ declare class WebAnalytics {
55
52
  reportError(error: Error): void;
56
53
  private canTrack;
57
54
  private isCookieless;
58
- private ensureActive;
55
+ private isActive;
59
56
  private activate;
60
57
  private startExtensions;
61
58
  private stopExtensions;
@@ -65,30 +62,27 @@ declare class WebAnalytics {
65
62
  private readonly onVisibilityChange;
66
63
  private readonly onPageHide;
67
64
  private readonly onPageShow;
68
- private navigate;
69
- private enterPage;
65
+ private readonly onResume;
70
66
  private leavePage;
67
+ private checkpoint;
71
68
  private pauseVisit;
72
69
  private resumeVisit;
73
- private touch;
74
- private startHeartbeat;
75
- private stopHeartbeat;
76
70
  private send;
77
71
  }
78
72
  //#endregion
79
73
  //#region src/client.d.ts
80
74
  /** Creates an independent client. Call `start()` when it should begin tracking. */
81
- declare function createClient(options: WebAnalyticsOptions): WebAnalytics;
75
+ export declare function createClient(options: WebAnalyticsOptions): WebAnalytics;
82
76
  /** Initializes and starts the shared client used by the convenience functions. */
83
- declare function init(options: WebAnalyticsOptions): WebAnalytics;
77
+ export declare function init(options: WebAnalyticsOptions): WebAnalytics;
84
78
  /** Stops and forgets the shared client. Independent clients are unaffected. */
85
- declare function shutdown(): void;
86
- declare function pageview(properties?: Record<string, unknown>): void;
87
- declare function track(name: string, properties?: Record<string, unknown>): void;
88
- declare function identify(user: IdentifyUser): Promise<boolean>;
89
- declare function identify(externalId: string, email?: string, options?: IdentifyOptions): Promise<boolean>;
90
- declare function logout(resetAnonymousIdentity?: boolean): void;
91
- declare function setConsentMode(mode: ConsentMode): void;
92
- declare function reportError(error: Error): void;
79
+ export declare function shutdown(): void;
80
+ export declare function pageview(properties?: Record<string, unknown>): void;
81
+ export declare function track(name: string, properties?: Record<string, unknown>): void;
82
+ export declare function identify(user: IdentifyUser): Promise<boolean>;
83
+ export declare function identify(externalId: string, email?: string, options?: IdentifyOptions): Promise<boolean>;
84
+ export declare function logout(resetAnonymousIdentity?: boolean): void;
85
+ export declare function setConsentMode(mode: ConsentMode): void;
86
+ export declare function reportError(error: Error): void;
93
87
  //#endregion
94
- export { type AnalyticsExtension, type AnalyticsExtensionContext, type ConsentMode, type ExtensionCleanup, type ExtensionHandler, type ExtensionHook, type ExtensionHooks, type IdentifyOptions, type IdentifyUser, WebAnalytics, type WebAnalyticsOptions, createClient, identify, init, logout, pageview, reportError, setConsentMode, shutdown, track };
88
+ export type { AnalyticsExtension, AnalyticsExtensionContext, ConsentMode, ExtensionCleanup, ExtensionHandler, ExtensionHook, ExtensionHooks, IdentifyOptions, IdentifyUser, WebAnalyticsOptions };
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 ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { a as resetSession, o as touchActivity, r as getSessionContext, t as sendData } from "./chunks/send-data-DFZnsaL2.js";
3
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-KgQDX74Q.js";
1
+ import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
+ import { a as resetSession, n as createId, o as touchActivity, r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
+ import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-xX7o7oZz.js";
4
4
  //#region src/extensions.ts
5
5
  var ExtensionHost = class {
6
6
  runtime;
@@ -104,15 +104,8 @@ var WebAnalytics = class {
104
104
  heartbeatTimer = null;
105
105
  extensions = null;
106
106
  pageKey = "";
107
- entry = {
108
- path: "",
109
- url: "",
110
- search: ""
111
- };
112
- left = false;
113
- visitDuration = 0;
114
- visitStartedAt = null;
115
- pendingPageviewTrigger = null;
107
+ visit = null;
108
+ pendingPageview = null;
116
109
  consentMode;
117
110
  constructor(options) {
118
111
  this.options = options;
@@ -129,29 +122,58 @@ var WebAnalytics = class {
129
122
  }
130
123
  destroy() {
131
124
  if (this.started === null) return;
132
- if (this.started && typeof window !== "undefined" && this.canTrack()) this.leavePage();
125
+ this.leavePage();
133
126
  this.started = null;
134
- this.pendingPageviewTrigger = null;
135
- this.stopHeartbeat();
127
+ this.pendingPageview = null;
136
128
  for (const cleanup of this.cleanup.splice(0).reverse()) cleanup();
137
129
  this.stopExtensions(false);
138
130
  }
139
131
  pageview(properties = {}) {
140
- if (!this.ensureActive()) return;
141
- const includeHash = this.options.trackHash ?? false;
142
- const key = `${location.pathname}|${location.search}|${includeHash ? location.hash : ""}`;
143
- if (key === this.pageKey) return;
144
- this.pageKey = key;
145
- this.send("pageview", properties);
132
+ if (!this.isActive()) return;
133
+ const key = location.pathname + location.search + (this.options.trackHash ? location.hash : "");
134
+ if (key !== this.pageKey) {
135
+ this.leavePage();
136
+ if (this.pageKey) this.extensions?.emit("pageChange", { url: getPageContext(this.options.trackHash).url ?? "" });
137
+ this.pageKey = key;
138
+ }
139
+ if (document.visibilityState !== "visible") {
140
+ this.pendingPageview = properties;
141
+ return;
142
+ }
143
+ this.pendingPageview = null;
144
+ if (document.hasFocus()) touchActivity(this.options.siteKey, this.isCookieless());
145
+ const session = getSessionContext(this.options.siteKey, this.isCookieless());
146
+ const userId = this.getAnonymousId();
147
+ if (this.visit?.session.sessionId === session.sessionId && this.visit.context.userId === (userId || void 0)) return;
148
+ this.leavePage();
149
+ this.visit = {
150
+ id: createId(),
151
+ session,
152
+ context: {
153
+ ...getPageContext(this.options.trackHash),
154
+ userId: userId || void 0,
155
+ sessionId: session.sessionId,
156
+ windowId: session.windowId
157
+ },
158
+ duration: 0,
159
+ startedAt: null,
160
+ reportedDuration: -1
161
+ };
162
+ this.send("pageview", {
163
+ ...properties,
164
+ visit_id: this.visit.id
165
+ }, this.visit.context);
166
+ this.resumeVisit();
146
167
  }
147
168
  track(name, properties = {}) {
148
- if (!this.ensureActive()) return;
169
+ if (!this.isActive()) return;
149
170
  const eventName = name.trim();
150
171
  if (!eventName || eventName === "pageview" || eventName === "page_leave" || eventName === "error" || eventName === "outbound_link") return;
172
+ this.pageview({ trigger: "session" });
151
173
  this.send(eventName, properties);
152
174
  }
153
175
  async identify(userOrExternalId, email, options = {}) {
154
- if (!this.ensureActive() || !this.canTrack() || this.isCookieless()) return false;
176
+ if (!this.isActive() || this.isCookieless()) return false;
155
177
  const user = typeof userOrExternalId === "string" ? {
156
178
  id: userOrExternalId,
157
179
  email,
@@ -165,7 +187,7 @@ var WebAnalytics = class {
165
187
  "phone",
166
188
  "avatarUrl"
167
189
  ].filter((field) => user[field] === null);
168
- return await sendData({
190
+ return sendData({
169
191
  url: `${this.baseUrl}${URLS.identify}`,
170
192
  data: JSON.stringify({
171
193
  token: this.options.siteKey,
@@ -188,9 +210,11 @@ var WebAnalytics = class {
188
210
  });
189
211
  }
190
212
  logout(resetAnonymousIdentity = true) {
191
- if (!this.ensureActive()) return;
213
+ if (!this.isActive()) return;
214
+ this.leavePage();
192
215
  if (resetAnonymousIdentity) resetAnonymousId(this.options.siteKey, this.isCookieless());
193
216
  resetSession(this.options.siteKey, this.isCookieless());
217
+ this.pageview({ trigger: "logout" });
194
218
  }
195
219
  setConsentMode(mode) {
196
220
  if (mode === this.consentMode) return;
@@ -201,26 +225,21 @@ var WebAnalytics = class {
201
225
  if (wasTracking && willTrack && wasCookieless !== willBeCookieless) this.leavePage();
202
226
  this.consentMode = mode;
203
227
  if (!willTrack) {
204
- this.pauseVisit();
205
- this.stopHeartbeat();
206
- this.pendingPageviewTrigger = null;
228
+ this.leavePage();
229
+ this.pendingPageview = null;
207
230
  this.stopExtensions(true);
208
231
  return;
209
232
  }
210
233
  if (wasTracking) {
211
234
  this.extensions?.emit("consentChange", { cookieless: willBeCookieless });
212
- if (wasCookieless !== willBeCookieless) {
213
- this.enterPage();
214
- this.pageKey = "";
215
- this.pageview({ trigger: "consent" });
216
- }
235
+ if (wasCookieless !== willBeCookieless) this.pageview({ trigger: "consent" });
217
236
  } else if (this.started) this.activate("consent");
218
237
  }
219
238
  getAnonymousId() {
220
239
  return this.canTrack() ? getAnonymousId(this.options.siteKey, this.isCookieless()) : "";
221
240
  }
222
241
  reportError(error) {
223
- if (!this.ensureActive() || !this.canTrack()) return;
242
+ if (!this.isActive()) return;
224
243
  this.extensions?.emit("error", { error });
225
244
  }
226
245
  canTrack() {
@@ -229,17 +248,12 @@ var WebAnalytics = class {
229
248
  isCookieless() {
230
249
  return this.options.cookieless === true || this.consentMode === "anonymous";
231
250
  }
232
- ensureActive() {
233
- return this.started === true && typeof window !== "undefined";
251
+ isActive() {
252
+ return this.started === true && this.canTrack();
234
253
  }
235
254
  activate(trigger) {
236
- this.touch();
237
255
  this.startExtensions();
238
- this.enterPage();
239
- this.pageKey = "";
240
- this.startHeartbeat();
241
- if (document.visibilityState === "visible") this.pageview({ trigger });
242
- else this.pendingPageviewTrigger = trigger;
256
+ this.pageview({ trigger });
243
257
  }
244
258
  startExtensions() {
245
259
  if (this.extensions || !this.options.extensions?.length) return;
@@ -268,32 +282,28 @@ var WebAnalytics = class {
268
282
  this.bind(document, "visibilitychange", this.onVisibilityChange);
269
283
  this.bind(window, "pagehide", this.onPageHide);
270
284
  this.bind(window, "pageshow", this.onPageShow);
285
+ this.bind(window, "focus", this.onResume);
286
+ this.bind(window, "blur", () => this.pauseVisit());
271
287
  this.bind(window, "popstate", this.onNavigate);
272
288
  if (this.options.trackHash) this.bind(window, "hashchange", this.onNavigate);
273
- this.cleanup.push(observeHistory(() => this.navigate()));
289
+ this.cleanup.push(observeHistory(this.onNavigate));
274
290
  }
275
- bind(target, type, listener, options) {
276
- target.addEventListener(type, listener, options);
277
- this.cleanup.push(() => target.removeEventListener(type, listener, options));
291
+ bind(target, type, listener) {
292
+ target.addEventListener(type, listener);
293
+ this.cleanup.push(() => target.removeEventListener(type, listener));
278
294
  }
279
- onNavigate = () => this.navigate();
295
+ onNavigate = () => this.pageview({ trigger: "navigation" });
280
296
  onVisibilityChange = () => {
281
297
  if (!this.canTrack()) return;
282
- this.touch();
283
298
  if (document.visibilityState === "hidden") {
284
299
  this.pauseVisit();
285
300
  this.extensions?.emit("pageHide", {
286
301
  persisted: false,
287
302
  terminal: false
288
303
  });
289
- this.stopHeartbeat();
290
304
  return;
291
305
  }
292
- this.startHeartbeat();
293
- this.resumeVisit();
294
- const trigger = this.pendingPageviewTrigger;
295
- this.pendingPageviewTrigger = null;
296
- if (trigger) this.pageview({ trigger });
306
+ this.onResume();
297
307
  };
298
308
  onPageHide = (event) => {
299
309
  if (!this.canTrack()) return;
@@ -307,82 +317,69 @@ var WebAnalytics = class {
307
317
  };
308
318
  onPageShow = (event) => {
309
319
  if (!this.canTrack()) return;
310
- if (document.visibilityState === "visible") this.resumeVisit();
320
+ this.onResume();
311
321
  this.extensions?.emit("pageShow", { persisted: event.persisted });
312
322
  };
313
- navigate() {
314
- if (this.started !== true || !this.canTrack()) return;
315
- const includeHash = this.options.trackHash ?? false;
316
- if (`${location.pathname}${includeHash ? location.hash : ""}` === this.entry.path && location.search === this.entry.search) return;
317
- const url = sanitizeUrl(location.href, includeHash);
318
- this.extensions?.emit("pageChange", { url });
319
- this.leavePage();
320
- this.enterPage();
321
- this.pageview({ trigger: "navigation" });
322
- }
323
- enterPage() {
324
- const includeHash = this.options.trackHash ?? false;
325
- const hash = includeHash ? location.hash : "";
326
- this.entry = {
327
- path: `${location.pathname}${hash}`,
328
- url: sanitizeUrl(location.href, includeHash),
329
- search: location.search
330
- };
331
- this.left = false;
332
- this.visitDuration = 0;
333
- this.visitStartedAt = document.visibilityState === "visible" ? Date.now() : null;
334
- }
323
+ onResume = () => {
324
+ this.pageview(this.pendingPageview ?? { trigger: "session" });
325
+ this.resumeVisit();
326
+ };
335
327
  leavePage() {
336
- if (this.started !== true || this.left || !this.canTrack()) return;
337
328
  this.pauseVisit();
338
- this.left = true;
339
- const session = getSessionContext(this.options.siteKey, this.isCookieless());
329
+ this.visit = null;
330
+ }
331
+ checkpoint() {
332
+ const visit = this.visit;
333
+ if (!visit || !this.isActive()) return;
334
+ if (visit.startedAt !== null) {
335
+ const now = performance.now();
336
+ visit.duration += now - visit.startedAt;
337
+ visit.startedAt = now;
338
+ }
339
+ const duration = Math.round(visit.duration);
340
+ if (duration === visit.reportedDuration) return;
341
+ visit.reportedDuration = duration;
340
342
  this.send("page_leave", {
341
- time_on_page: this.visitDuration,
342
- session_duration: Date.now() - session.sessionStart
343
- }, {
344
- page: this.entry.path,
345
- url: this.entry.url
346
- });
343
+ visit_id: visit.id,
344
+ time_on_page: duration,
345
+ session_duration: Math.max(0, Date.now() - visit.session.sessionStart)
346
+ }, visit.context);
347
347
  }
348
348
  pauseVisit() {
349
- if (this.visitStartedAt === null) return;
350
- this.visitDuration += Math.max(0, Date.now() - this.visitStartedAt);
351
- this.visitStartedAt = null;
349
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
350
+ this.heartbeatTimer = null;
351
+ this.checkpoint();
352
+ if (this.visit) this.visit.startedAt = null;
352
353
  }
353
354
  resumeVisit() {
354
- if (!this.left && this.visitStartedAt === null) this.visitStartedAt = Date.now();
355
- }
356
- touch() {
357
- if (this.canTrack()) touchActivity(this.options.siteKey, this.isCookieless());
358
- }
359
- startHeartbeat() {
360
- this.stopHeartbeat();
355
+ if (!this.isActive() || !this.visit || this.visit.startedAt !== null || document.visibilityState !== "visible" || !document.hasFocus()) return;
356
+ this.visit.startedAt = performance.now();
361
357
  this.heartbeatTimer = setInterval(() => {
362
- if (document.visibilityState === "hidden") return this.stopHeartbeat();
363
- this.touch();
364
- }, 3e5);
365
- }
366
- stopHeartbeat() {
367
- if (!this.heartbeatTimer) return;
368
- clearInterval(this.heartbeatTimer);
369
- this.heartbeatTimer = null;
358
+ if (document.visibilityState !== "visible" || !document.hasFocus()) {
359
+ this.pauseVisit();
360
+ return;
361
+ }
362
+ this.pageview({ trigger: "session" });
363
+ this.checkpoint();
364
+ }, 15e3);
370
365
  }
371
- send(event, properties = {}, dimensions = {}) {
372
- if (this.started !== true || !this.canTrack()) return;
373
- const cookieless = this.isCookieless();
374
- const userId = getAnonymousId(this.options.siteKey, cookieless);
375
- const session = getSessionContext(this.options.siteKey, cookieless);
366
+ send(event, properties = {}, context) {
367
+ if (!this.isActive()) return;
368
+ if (!context) {
369
+ const session = getSessionContext(this.options.siteKey, this.isCookieless());
370
+ context = {
371
+ ...getPageContext(this.options.trackHash),
372
+ userId: this.getAnonymousId() || void 0,
373
+ sessionId: session.sessionId,
374
+ windowId: session.windowId
375
+ };
376
+ }
376
377
  let data;
377
378
  try {
378
379
  data = JSON.stringify({
379
380
  token: this.options.siteKey,
380
- ...userId ? { userId } : {},
381
- sessionId: session.sessionId,
382
- windowId: session.windowId,
383
381
  event,
384
- ...getPageContext(this.options.trackHash ?? false),
385
- ...dimensions,
382
+ ...context,
386
383
  properties
387
384
  });
388
385
  } catch (error) {
@@ -1,10 +1,9 @@
1
1
  import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
2
2
  //#region src/outbound-links.d.ts
3
- declare function outboundLinks(): AnalyticsExtension;
4
- declare class OutboundLinkTracker {
3
+ export declare function outboundLinks(): AnalyticsExtension;
4
+ export declare class OutboundLinkTracker {
5
5
  private lastActivation;
6
6
  getHref(event: Event): string | null;
7
7
  private isDuplicate;
8
8
  }
9
- //#endregion
10
- export { OutboundLinkTracker, outboundLinks };
9
+ //#endregion
package/dist/replay.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
2
2
  import { record } from "@rrweb/record";
3
3
  import { LogLevel } from "@rrweb/rrweb-plugin-console-record";
4
- //#region ../../node_modules/.bun/@rrweb+types@2.1.1/node_modules/@rrweb/types/dist/index.d.ts
4
+ //#region ../../node_modules/.bun/@rrweb+types@2.1.4/node_modules/@rrweb/types/dist/index.d.ts
5
5
  declare global {
6
6
  interface Window {
7
7
  FontFace: typeof FontFace;
@@ -17,7 +17,7 @@ type ReplayMutationThrottleOptions = {
17
17
  //#endregion
18
18
  //#region src/replay.d.ts
19
19
  type RecordOptions = NonNullable<Parameters<typeof record>[0]>;
20
- interface ReplayTrackerOptions {
20
+ export interface ReplayTrackerOptions {
21
21
  siteKey: string;
22
22
  baseUrl?: string;
23
23
  debug?: boolean;
@@ -47,9 +47,9 @@ interface ReplayTrackerOptions {
47
47
  consoleStringLengthLimit?: number;
48
48
  mutationThrottle?: ReplayMutationThrottleOptions;
49
49
  }
50
- type SessionReplayExtensionOptions = Omit<ReplayTrackerOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless">;
51
- declare function sessionReplay(options?: SessionReplayExtensionOptions): AnalyticsExtension;
52
- declare class ReplayTracker {
50
+ export type SessionReplayExtensionOptions = Omit<ReplayTrackerOptions, "siteKey" | "baseUrl" | "debug" | "trackHash" | "cookieless">;
51
+ export declare function sessionReplay(options?: SessionReplayExtensionOptions): AnalyticsExtension;
52
+ export default class ReplayTracker {
53
53
  private readonly options;
54
54
  private readonly endpoint;
55
55
  private readonly compressionSupported;
@@ -85,6 +85,7 @@ declare class ReplayTracker {
85
85
  private unsubscribeRotation?;
86
86
  private lastCookielessMode;
87
87
  private terminalFlushRequested;
88
+ private initialFlushPending;
88
89
  private overflowed;
89
90
  private replayEventSequence;
90
91
  private readonly mutationThrottler;
@@ -126,5 +127,4 @@ declare class ReplayTracker {
126
127
  private retryDelay;
127
128
  private sendBatch;
128
129
  }
129
- //#endregion
130
- export { ReplayTrackerOptions, SessionReplayExtensionOptions, ReplayTracker as default, sessionReplay };
130
+ //#endregion
package/dist/replay.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { i as onSessionRotated, l as setStorageItem, n as createId, o as touchActivity, r as getSessionContext, s as getStorageItem, t as sendData } from "./chunks/send-data-DFZnsaL2.js";
3
- import { t as getAnonymousId } from "./chunks/identifiers-KgQDX74Q.js";
2
+ import { i as onSessionRotated, l as setStorageItem, n as createId, o as touchActivity, r as getSessionContext, s as getStorageItem, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-xX7o7oZz.js";
4
4
  import { record } from "@rrweb/record";
5
5
  import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
6
6
  //#region src/replay-mutation-throttler.ts
@@ -122,6 +122,7 @@ var ReplayTracker = class {
122
122
  unsubscribeRotation;
123
123
  lastCookielessMode;
124
124
  terminalFlushRequested = false;
125
+ initialFlushPending = true;
125
126
  overflowed = false;
126
127
  replayEventSequence = 0;
127
128
  mutationThrottler;
@@ -204,6 +205,7 @@ var ReplayTracker = class {
204
205
  this.started = true;
205
206
  this.disposed = false;
206
207
  this.terminalFlushRequested = false;
208
+ this.initialFlushPending = true;
207
209
  this.overflowed = false;
208
210
  this.replayEventSequence = 0;
209
211
  this.viewId = createId();
@@ -371,6 +373,7 @@ var ReplayTracker = class {
371
373
  this.minLengthFlushTask = null;
372
374
  this.startTime = next.sessionStart;
373
375
  this.chunkStartedAt = Date.now();
376
+ this.initialFlushPending = true;
374
377
  }
375
378
  onEvent = (event, _isCheckout) => {
376
379
  if (this.overflowed) return;
@@ -429,10 +432,17 @@ var ReplayTracker = class {
429
432
  }, 0);
430
433
  };
431
434
  scheduleMinLengthFlush() {
432
- if (this.minLengthFlushTask || this.events.length === 0 || this.hasReachedMinLength()) return;
435
+ if (!this.initialFlushPending || this.events.length === 0 || this.minReplayLengthMs <= 0) return;
436
+ if (this.hasReachedMinLength()) {
437
+ this.initialFlushPending = false;
438
+ this.requestFlush("minLength");
439
+ return;
440
+ }
441
+ if (this.minLengthFlushTask) return;
433
442
  const remainingMs = Math.max(0, this.minReplayLengthMs - (Date.now() - this.startTime));
434
443
  this.minLengthFlushTask = setTimeout(() => {
435
444
  this.minLengthFlushTask = null;
445
+ this.initialFlushPending = false;
436
446
  this.requestFlush("minLength");
437
447
  }, Math.min(remainingMs, MAX_TIMEOUT_MS));
438
448
  }
@@ -600,6 +610,8 @@ var ReplayTracker = class {
600
610
  const json = JSON.stringify(batch);
601
611
  const lowLatencySafe = lowLatency && textEncoder.encode(json).byteLength <= 61440;
602
612
  const sendOptions = {
613
+ debug: this.options.debug,
614
+ debugPrefix: "[Replay]",
603
615
  useBeacon: lowLatencySafe,
604
616
  keepalive: lowLatencySafe
605
617
  };
@@ -1,31 +1,32 @@
1
1
  import { t as AnalyticsExtension } from "./chunks/extensions-DW7tJY0T.js";
2
2
  //#region src/web-vitals.d.ts
3
- interface WebVitalsOptions {
3
+ export interface WebVitalsOptions {
4
4
  siteKey: string;
5
5
  baseUrl?: string;
6
6
  debug?: boolean;
7
- attribution?: boolean;
8
7
  trackHash?: boolean;
9
8
  cookieless?: boolean;
10
9
  }
11
- type WebVitalsExtensionOptions = Pick<WebVitalsOptions, "attribution">;
12
- declare function webVitals(options?: WebVitalsExtensionOptions): AnalyticsExtension;
13
- declare class WebVitalsTracker {
10
+ export declare function webVitals(): AnalyticsExtension;
11
+ export default class WebVitalsTracker {
14
12
  private readonly options;
15
13
  private readonly endpoint;
16
14
  private metrics;
17
15
  private started;
18
- private flushing;
16
+ private sending?;
17
+ private generation;
18
+ private unsubscribe?;
19
+ private readonly sentMetrics;
19
20
  private initialUrl;
20
21
  private session;
21
22
  private cookieless;
22
23
  constructor(options: WebVitalsOptions);
23
24
  setCookielessMode(cookieless: boolean): void;
24
- start(): Promise<void>;
25
+ start(): void;
25
26
  stop(discard?: boolean): void;
26
27
  onPageHidden(persisted?: boolean): void;
27
28
  private onMetric;
29
+ private discard;
28
30
  private flush;
29
31
  }
30
- //#endregion
31
- export { WebVitalsExtensionOptions, WebVitalsOptions, WebVitalsTracker as default, webVitals };
32
+ //#endregion
@@ -1,12 +1,43 @@
1
1
  import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as ANALYTICS_BASE } from "./chunks/api-urls-DWk43fu6.js";
2
- import { r as getSessionContext, t as sendData } from "./chunks/send-data-DFZnsaL2.js";
2
+ import { r as getSessionContext, t as sendData } from "./chunks/send-data-Dplz7m4c.js";
3
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals/attribution";
4
+ //#region src/utils/web-vitals-collector.ts
5
+ const collectors = /* @__PURE__ */ new WeakMap();
6
+ 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
+ return () => {
31
+ collector.subscribers.delete(subscriber);
32
+ };
33
+ }
34
+ //#endregion
3
35
  //#region src/web-vitals.ts
4
- function webVitals(options = {}) {
36
+ function webVitals() {
5
37
  return {
6
38
  name: "web-vitals",
7
- async setup(context) {
39
+ setup(context) {
8
40
  const tracker = new WebVitalsTracker({
9
- ...options,
10
41
  siteKey: context.siteKey,
11
42
  baseUrl: context.baseUrl,
12
43
  debug: context.debug,
@@ -15,7 +46,7 @@ function webVitals(options = {}) {
15
46
  });
16
47
  context.on("pageHide", ({ persisted }) => tracker.onPageHidden(persisted));
17
48
  context.on("consentChange", ({ cookieless }) => tracker.setCookielessMode(cookieless));
18
- await tracker.start();
49
+ tracker.start();
19
50
  return ({ discard }) => tracker.stop(discard);
20
51
  }
21
52
  };
@@ -32,7 +63,10 @@ var WebVitalsTracker = class {
32
63
  endpoint;
33
64
  metrics = /* @__PURE__ */ new Map();
34
65
  started = false;
35
- flushing = false;
66
+ sending;
67
+ generation = 0;
68
+ unsubscribe;
69
+ sentMetrics = /* @__PURE__ */ new Map();
36
70
  initialUrl = "";
37
71
  session = null;
38
72
  cookieless;
@@ -44,77 +78,83 @@ var WebVitalsTracker = class {
44
78
  setCookielessMode(cookieless) {
45
79
  if (this.cookieless === cookieless) return;
46
80
  this.cookieless = cookieless;
47
- this.metrics.clear();
81
+ this.discard();
48
82
  this.session = this.started ? getSessionContext(this.options.siteKey, cookieless) : null;
49
83
  }
50
- async start() {
84
+ start() {
51
85
  if (this.started || typeof window === "undefined") return;
52
86
  this.started = true;
53
- this.initialUrl = sanitizeUrl(window.location.href, this.options.trackHash ?? false);
87
+ this.initialUrl = sanitizeUrl(window.performance?.getEntriesByType("navigation")[0]?.name || window.location.href, this.options.trackHash ?? false);
54
88
  this.session = getSessionContext(this.options.siteKey, this.cookieless);
55
- const vitals = this.options.attribution ? await import("web-vitals/attribution") : await import("web-vitals");
56
- if (!this.started) return;
57
- const changes = { reportAllChanges: true };
58
- vitals.onCLS(this.onMetric, changes);
59
- vitals.onINP(this.onMetric, changes);
60
- vitals.onLCP(this.onMetric, changes);
61
- vitals.onFCP(this.onMetric);
62
- vitals.onTTFB(this.onMetric);
89
+ this.unsubscribe = subscribeToVitals(this.onMetric);
63
90
  }
64
91
  stop(discard = false) {
65
92
  if (!this.started) return;
66
93
  this.started = false;
67
- if (discard) this.metrics.clear();
94
+ this.unsubscribe?.();
95
+ this.unsubscribe = void 0;
96
+ if (discard) this.discard();
68
97
  else this.flush();
69
98
  }
70
99
  onPageHidden(persisted = false) {
71
100
  if (persisted) return;
72
- queueMicrotask(() => void this.flush());
101
+ const generation = this.generation;
102
+ queueMicrotask(() => {
103
+ if (generation === this.generation) this.flush();
104
+ });
73
105
  }
74
106
  onMetric = (metric) => {
75
107
  if (!this.started) return;
76
108
  const name = metric.name;
77
109
  if (!METRIC_NAMES.has(name) || !Number.isFinite(metric.value) || metric.value < 0) return;
78
- const { id, rating, delta, navigationType } = metric;
79
- const attribution = this.options.attribution && "attribution" in metric && metric.attribution ? metric.attribution : void 0;
80
- this.metrics.set(name, {
81
- metric: name,
82
- value: metric.value,
83
- attributes: {
84
- id,
85
- rating,
86
- delta,
87
- navigationType,
88
- ...attribution
89
- }
90
- });
110
+ this.metrics.set(name, metric);
91
111
  };
112
+ discard() {
113
+ this.generation++;
114
+ this.metrics.clear();
115
+ this.sentMetrics.clear();
116
+ }
92
117
  async flush() {
93
- if (!this.metrics.size || this.flushing) return;
94
- this.flushing = true;
118
+ const generation = this.generation;
119
+ while (this.sending) await this.sending;
120
+ if (generation !== this.generation || !this.metrics.size) return;
95
121
  const batch = this.metrics;
96
- this.metrics = /* @__PURE__ */ new Map();
97
122
  const session = this.session ?? getSessionContext(this.options.siteKey, this.cookieless);
98
- let sent = false;
99
- try {
100
- sent = await sendData({
101
- url: this.endpoint,
102
- data: JSON.stringify({
103
- token: this.options.siteKey,
104
- sessionId: session.sessionId,
105
- windowId: session.windowId,
106
- vitals: [...batch.values()],
107
- metadata: { url: this.initialUrl }
108
- }),
109
- debug: this.options.debug,
110
- debugPrefix: "[WebVitals]"
111
- });
112
- } finally {
113
- if (!sent) {
114
- for (const [name, vital] of batch) if (!this.metrics.has(name)) this.metrics.set(name, vital);
115
- }
116
- this.flushing = false;
117
- }
123
+ const data = JSON.stringify({
124
+ token: this.options.siteKey,
125
+ sessionId: session.sessionId,
126
+ windowId: session.windowId,
127
+ vitals: [...batch.values()].map(({ name, value, id, rating, navigationType, attribution }) => {
128
+ const previous = this.sentMetrics.get(name);
129
+ return {
130
+ metric: name,
131
+ value,
132
+ attributes: {
133
+ ...attribution,
134
+ id,
135
+ rating,
136
+ navigationType,
137
+ delta: value - (previous?.id === id ? previous.value : 0)
138
+ }
139
+ };
140
+ }),
141
+ metadata: { url: this.initialUrl }
142
+ });
143
+ this.metrics = /* @__PURE__ */ new Map();
144
+ this.sending = sendData({
145
+ url: this.endpoint,
146
+ data,
147
+ debug: this.options.debug,
148
+ debugPrefix: "[WebVitals]"
149
+ });
150
+ const sent = await this.sending;
151
+ this.sending = void 0;
152
+ if (generation !== this.generation) return;
153
+ for (const [name, vital] of batch) if (sent) this.sentMetrics.set(name, {
154
+ id: vital.id,
155
+ value: vital.value
156
+ });
157
+ else if (!this.metrics.has(name)) this.metrics.set(name, vital);
118
158
  }
119
159
  };
120
160
  //#endregion
package/package.json CHANGED
@@ -42,7 +42,7 @@
42
42
  "publishConfig": {
43
43
  "access": "public"
44
44
  },
45
- "version": "0.8.1",
45
+ "version": "0.9.0",
46
46
  "scripts": {
47
47
  "build": "tsdown && bun run check-size",
48
48
  "dev": "bun run build",
@@ -51,14 +51,14 @@
51
51
  "check-size": "node scripts/check-bundle-size.mjs"
52
52
  },
53
53
  "devDependencies": {
54
- "@rrweb/types": "^2.1.1",
55
- "@types/bun": "^1.4.0",
56
- "tsdown": "^0.22.14",
57
- "typescript": "^6.0.3"
54
+ "@rrweb/types": "^2.1.4",
55
+ "@types/bun": "^1.4.2",
56
+ "tsdown": "^0.23.0",
57
+ "typescript": "^7.0.2"
58
58
  },
59
59
  "dependencies": {
60
- "@rrweb/record": "^2.1.1",
61
- "@rrweb/rrweb-plugin-console-record": "^2.1.1",
60
+ "@rrweb/record": "^2.1.4",
61
+ "@rrweb/rrweb-plugin-console-record": "^2.1.4",
62
62
  "web-vitals": "^6.2.1"
63
63
  }
64
64
  }