@faststats/web 0.11.0 → 0.12.1

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
@@ -5,8 +5,8 @@
5
5
 
6
6
  The base SDK handles analytics, identity, sessions, navigation, outbound link
7
7
  clicks, and consent.
8
- Error tracking, Web Vitals, and session replay are independent extensions, so
9
- applications only download the products they use.
8
+ Error tracking, Web Vitals, session replay, and feature flags are independent
9
+ extensions, so applications only download the products they use.
10
10
 
11
11
  ```ts
12
12
  import { init, track } from "@faststats/web";
@@ -52,8 +52,10 @@ Consent remains client-local: use `"anonymous"` for cookieless collection,
52
52
  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.
@@ -68,15 +70,65 @@ Each visit retains its original identity, session, and page metadata. A session
68
70
  or identity change starts a new visit, even on the same route. Both persistent
69
71
  and cookieless sessions expire after 30 minutes of inactivity or 24 hours of age.
70
72
 
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.
73
+ `session_duration` is elapsed wall-clock time from the session start to the last
74
+ counted activity. It includes time away between two active periods of the same
75
+ session, so it is separate from active time on the page. An idle visitor stops
76
+ extending the session, which then expires after 30 minutes like any other.
74
77
 
75
78
  Delivery is best effort: a sudden process kill or network failure can lose time
76
79
  since the last delivered checkpoint. The collector must accept successive events
77
80
  for the same visitor and URL; deploy the removal of its five-second debounce
78
81
  before releasing this SDK.
79
82
 
83
+ ### Feature flags
84
+
85
+ `featureFlags()` loads the project's flags for the visitor the client tracks and
86
+ resolves them synchronously from memory:
87
+
88
+ ```ts
89
+ import { init } from "@faststats/web";
90
+ import { featureFlags } from "@faststats/web/flags";
91
+
92
+ const flags = featureFlags({ flags: ["new-checkout", "max-uploads"] });
93
+ init({ siteKey: "site_123", extensions: [flags] });
94
+
95
+ await flags.ready();
96
+ if (flags.isEnabled("new-checkout")) showNewCheckout();
97
+ const maxUploads = flags.get("max-uploads", 10);
98
+ ```
99
+
100
+ Every loaded flag is recorded as an exposure, whether or not the app reads it,
101
+ so list the keys the app uses in `flags`. Without it every flag in the project
102
+ is loaded.
103
+
104
+ Flags are evaluated for the anonymous visitor ID, and the session ID links each
105
+ evaluation to analytics. `identify()` re-evaluates them for that user, so
106
+ rollouts follow the user across devices, and `logout()` goes back to a new
107
+ anonymous visitor. When the user is known at startup, pass `userId` to skip the
108
+ anonymous load. A cookieless visitor has no lasting ID, so rollouts hold for the
109
+ session only. Flags are not loaded while consent is `"denied"`.
110
+
111
+ `get()` returns the default passed at the call site while flags are loading, and
112
+ when the flag does not exist, is archived, has a different type, or the service
113
+ cannot be reached within `timeoutMs`. `details(key)` adds the variant and reason
114
+ (`rule_match`, `target_match`, `fallthrough`, `disabled`,
115
+ `prerequisite_failed`).
116
+
117
+ ```ts
118
+ flags.subscribe(() => render(flags.status)); // "idle" | "loading" | "ready" | "error"
119
+ await flags.setContext({ attributes: { plan: "pro" } });
120
+ await flags.reload();
121
+ ```
122
+
123
+ | Option | |
124
+ | ------------ | -------------------------------------------------------- |
125
+ | `flags` | The flag keys to load. |
126
+ | `userId` | Your ID for a signed-in user, when known at startup. |
127
+ | `attributes` | Anything targeting rules should match on. |
128
+ | `endpoint` | Base URL of the flags service, when self-run. |
129
+ | `timeoutMs` | Aborts a slower load. Defaults to `3000`. |
130
+ | `fetch` | A `fetch` implementation to use instead of the global. |
131
+
80
132
  ### Network recording in session replay
81
133
 
82
134
  Network recording is disabled by default. Enable it with
@@ -13,6 +13,10 @@ type ExtensionHooks = {
13
13
  consentChange: {
14
14
  cookieless: boolean;
15
15
  };
16
+ /** `identify()` set a user, or `logout()` cleared one. */
17
+ identityChange: {
18
+ userId: string | undefined;
19
+ };
16
20
  error: {
17
21
  error: Error;
18
22
  };
@@ -1,4 +1,4 @@
1
- import { c as removeStorageItem, l as setStorageItem, n as createId, s as getStorageItem } from "./send-data-D520oab1.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) {
@@ -0,0 +1,83 @@
1
+ //#region src/utils/api-urls.ts
2
+ function resolveBaseUrl(baseUrl) {
3
+ return baseUrl?.replace(/\/+$/, "") || "https://metrics.faststats.dev";
4
+ }
5
+ const URLS = {
6
+ events: "/v1/web",
7
+ identify: "/v1/identify",
8
+ replay: "/v1/replay",
9
+ vitals: "/v1/vitals"
10
+ };
11
+ function sanitizeUrl(url, includeHash = false) {
12
+ try {
13
+ const parsed = new URL(url, location.href);
14
+ return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
15
+ } catch {
16
+ return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
17
+ }
18
+ }
19
+ const UTM_KEYS = [
20
+ "utm_source",
21
+ "utm_medium",
22
+ "utm_campaign",
23
+ "utm_term",
24
+ "utm_content"
25
+ ];
26
+ function getReferrer() {
27
+ const raw = document.referrer;
28
+ if (!raw) return null;
29
+ try {
30
+ if (new URL(raw, location.href).origin === location.origin) return null;
31
+ } catch {}
32
+ return sanitizeUrl(raw);
33
+ }
34
+ function getPageContext(includeHash = false) {
35
+ const hash = includeHash ? location.hash : "";
36
+ const ctx = {
37
+ page: `${location.pathname}${hash}`,
38
+ url: sanitizeUrl(location.href, includeHash),
39
+ referrer: getReferrer(),
40
+ title: document.title
41
+ };
42
+ if (!location.search) return ctx;
43
+ const sp = new URLSearchParams(location.search);
44
+ for (const key of UTM_KEYS) {
45
+ const value = sp.get(key);
46
+ if (value) ctx[key] = value;
47
+ }
48
+ return ctx;
49
+ }
50
+ //#endregion
51
+ //#region src/utils/send-data.ts
52
+ async function sendData(options) {
53
+ const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
54
+ if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
55
+ const body = data instanceof Blob ? data : new Blob([data], { type: contentType });
56
+ if (globalThis.navigator.sendBeacon(url, body)) {
57
+ if (debug) console.log(`${debugPrefix} Sent via beacon`);
58
+ return true;
59
+ }
60
+ } catch {}
61
+ const size = typeof data === "string" ? data.length : data instanceof Blob ? data.size : data.byteLength;
62
+ const post = (keepalive) => fetch(url, {
63
+ method: "POST",
64
+ body: data,
65
+ headers: { "Content-Type": contentType },
66
+ keepalive,
67
+ credentials: "omit"
68
+ });
69
+ try {
70
+ const useKeepalive = keepalive && size <= 61440;
71
+ const { ok, status } = await post(useKeepalive).catch((error) => {
72
+ if (!useKeepalive) throw error;
73
+ return post(false);
74
+ });
75
+ if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
76
+ return ok || !!settleRejected && status < 500 && status !== 429;
77
+ } catch {
78
+ if (debug) console.log(debugPrefix, "Failed to send");
79
+ return false;
80
+ }
81
+ }
82
+ //#endregion
83
+ export { sanitizeUrl as a, resolveBaseUrl as i, URLS as n, getPageContext as r, sendData as t };
@@ -1,54 +1,3 @@
1
- //#region src/utils/api-urls.ts
2
- function resolveBaseUrl(baseUrl) {
3
- return baseUrl?.replace(/\/+$/, "") || "https://metrics.faststats.dev";
4
- }
5
- const URLS = {
6
- events: "/v1/web",
7
- identify: "/v1/identify",
8
- replay: "/v1/replay",
9
- vitals: "/v1/vitals"
10
- };
11
- function sanitizeUrl(url, includeHash = false) {
12
- try {
13
- const parsed = new URL(url, location.href);
14
- return `${parsed.origin}${parsed.pathname}${includeHash ? parsed.hash : ""}`;
15
- } catch {
16
- return url.split(includeHash ? "?" : /[?#]/, 1)[0] ?? "";
17
- }
18
- }
19
- const UTM_KEYS = [
20
- "utm_source",
21
- "utm_medium",
22
- "utm_campaign",
23
- "utm_term",
24
- "utm_content"
25
- ];
26
- function getReferrer() {
27
- const raw = document.referrer;
28
- if (!raw) return null;
29
- try {
30
- const pageOrigin = new URL(location.href).origin;
31
- if (new URL(raw, location.href).origin === pageOrigin) return null;
32
- } catch {}
33
- return sanitizeUrl(raw);
34
- }
35
- function getPageContext(includeHash = false) {
36
- const hash = includeHash ? location.hash : "";
37
- const ctx = {
38
- page: `${location.pathname}${hash}`,
39
- url: sanitizeUrl(location.href, includeHash),
40
- referrer: getReferrer(),
41
- title: document.title || ""
42
- };
43
- if (!location.search) return ctx;
44
- const sp = new URLSearchParams(location.search);
45
- for (const key of UTM_KEYS) {
46
- const value = sp.get(key);
47
- if (value) ctx[key] = value;
48
- }
49
- return ctx;
50
- }
51
- //#endregion
52
1
  //#region src/utils/storage.ts
53
2
  function getStorageItem(name, key) {
54
3
  try {
@@ -188,43 +137,13 @@ function resetSession(siteKey, cookieless = false) {
188
137
  }
189
138
  function onSessionRotated(siteKey, cookieless, listener) {
190
139
  const key = memoryKey(siteKey, cookieless);
191
- let listeners = rotationListeners.get(key);
192
- if (!listeners) {
193
- listeners = /* @__PURE__ */ new Set();
194
- rotationListeners.set(key, listeners);
195
- }
140
+ const listeners = rotationListeners.get(key) ?? /* @__PURE__ */ new Set();
141
+ rotationListeners.set(key, listeners);
196
142
  listeners.add(listener);
197
143
  return () => {
198
- listeners?.delete(listener);
199
- if (listeners?.size === 0) rotationListeners.delete(key);
144
+ listeners.delete(listener);
145
+ if (listeners.size === 0) rotationListeners.delete(key);
200
146
  };
201
147
  }
202
148
  //#endregion
203
- //#region src/utils/send-data.ts
204
- async function sendData(options) {
205
- const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
206
- if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
207
- const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
208
- if (globalThis.navigator.sendBeacon(url, beaconBody)) {
209
- if (debug) console.log(`${debugPrefix} Sent via beacon`);
210
- return true;
211
- }
212
- } catch {}
213
- const size = typeof data === "string" ? data.length : data instanceof Blob ? data.size : data.byteLength;
214
- try {
215
- const { ok, status } = await fetch(url, {
216
- method: "POST",
217
- body: data,
218
- headers: { "Content-Type": contentType },
219
- keepalive: keepalive && size <= 61440,
220
- credentials: "omit"
221
- });
222
- if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
223
- return ok || !!settleRejected && status < 500 && status !== 429;
224
- } catch {
225
- if (debug) console.log(debugPrefix, "Failed to send");
226
- return false;
227
- }
228
- }
229
- //#endregion
230
- export { resetSession as a, removeStorageItem as c, getPageContext as d, resolveBaseUrl as f, onSessionRotated as i, setStorageItem as l, createId as n, touchActivity as o, sanitizeUrl as p, getSessionContext as r, getStorageItem as s, sendData as t, URLS as u };
149
+ export { touchActivity as a, setStorageItem as c, resetSession as i, getSessionContext as n, getStorageItem as o, onSessionRotated as r, removeStorageItem as s, createId as t };
package/dist/error.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
2
  //#region src/error.d.ts
3
3
  export type ErrorEntry = {
4
4
  error: string;
package/dist/error.js CHANGED
@@ -1,8 +1,9 @@
1
- import { d as getPageContext, f as resolveBaseUrl, r as getSessionContext, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
2
- import { t as getAnonymousId } from "./chunks/identifiers-BwGNL11R.js";
1
+ import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as sendData } from "./chunks/send-data-gBIF6DTb.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
3
4
  //#region src/error.ts
4
5
  const SDK_NAME = "@faststats/web";
5
- const SDK_VERSION = "0.11.0";
6
+ const SDK_VERSION = "0.12.1";
6
7
  function errorTracking(options = {}) {
7
8
  return {
8
9
  name: "error-tracking",
@@ -0,0 +1,79 @@
1
+ import { n as AnalyticsExtensionContext, r as ExtensionCleanup, t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
+ //#region src/flags.d.ts
3
+ export interface FeatureFlagsOptions {
4
+ /**
5
+ * Flag keys this app reads. Every loaded flag is recorded as an exposure,
6
+ * so list them to keep flags the app never shows out of analytics.
7
+ * Loads every flag when omitted.
8
+ */
9
+ flags?: string[];
10
+ /** Your own ID for a signed-in user. Rollouts follow it across devices. */
11
+ userId?: string;
12
+ /** Anything targeting rules should match on. */
13
+ attributes?: Record<string, unknown>;
14
+ /** Base URL of the feature flags service. */
15
+ endpoint?: string;
16
+ /** Aborts a load that takes longer, so flags serve their defaults. */
17
+ timeoutMs?: number;
18
+ fetch?: typeof fetch;
19
+ }
20
+ export type FeatureFlagReason = "disabled" | "prerequisite_failed" | "target_match" | "rule_match" | "fallthrough";
21
+ export interface FeatureFlagResult {
22
+ value: unknown;
23
+ variant: string;
24
+ reason: FeatureFlagReason;
25
+ }
26
+ export interface FeatureFlagsContext {
27
+ /** Pass `null` to go back to the anonymous visitor. */
28
+ userId?: string | null;
29
+ /** Replaces the attributes set before. */
30
+ attributes?: Record<string, unknown>;
31
+ }
32
+ /**
33
+ * `idle` until the client starts tracking, `loading` during the first load
34
+ * for a visitor, then `ready`, or `error` when that load failed.
35
+ */
36
+ export type FeatureFlagsStatus = "idle" | "loading" | "ready" | "error";
37
+ /**
38
+ * Loads flags from the FastStats flags service for the visitor the analytics
39
+ * client tracks and resolves them synchronously from memory.
40
+ */
41
+ export declare class FeatureFlags implements AnalyticsExtension {
42
+ private readonly options;
43
+ readonly name = "feature-flags";
44
+ private readonly listeners;
45
+ private context;
46
+ private results;
47
+ private currentStatus;
48
+ private latestLoad;
49
+ private subjectKey;
50
+ private loading;
51
+ private userId;
52
+ private attributes;
53
+ constructor(options?: FeatureFlagsOptions);
54
+ setup(context: AnalyticsExtensionContext): ExtensionCleanup;
55
+ get status(): FeatureFlagsStatus;
56
+ /**
57
+ * The flag's value, or `defaultValue` while flags are loading, when the flag
58
+ * does not exist or is archived, or when its type differs from the default's.
59
+ */
60
+ get(key: string, defaultValue: boolean): boolean;
61
+ get(key: string, defaultValue: string): string;
62
+ get(key: string, defaultValue: number): number;
63
+ get<T>(key: string, defaultValue: T): T;
64
+ isEnabled(key: string): boolean;
65
+ details(key: string): FeatureFlagResult | undefined;
66
+ /** Changes who flags are evaluated for and loads them again. */
67
+ setContext(context: FeatureFlagsContext): Promise<void>;
68
+ /** Resolves once the load in flight settles. Never rejects. */
69
+ ready(): Promise<void>;
70
+ /** Loads the flags again. Never rejects; a failed load sets `status`. */
71
+ reload(): Promise<void>;
72
+ /** Calls `listener` whenever the flags or `status` change. */
73
+ subscribe(listener: () => void): () => void;
74
+ private subject;
75
+ private evaluate;
76
+ private update;
77
+ }
78
+ export declare function featureFlags(options?: FeatureFlagsOptions): FeatureFlags;
79
+ //#endregion
package/dist/flags.js ADDED
@@ -0,0 +1,145 @@
1
+ import { n as getSessionContext } from "./chunks/session-manager-DUpF3mi3.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-DOtve3E5.js";
3
+ //#region src/flags.ts
4
+ const DEFAULT_ENDPOINT = "https://flags.faststats.dev";
5
+ const DEFAULT_TIMEOUT_MS = 3e3;
6
+ /**
7
+ * Loads flags from the FastStats flags service for the visitor the analytics
8
+ * client tracks and resolves them synchronously from memory.
9
+ */
10
+ var FeatureFlags = class {
11
+ options;
12
+ name = "feature-flags";
13
+ listeners = /* @__PURE__ */ new Set();
14
+ context = null;
15
+ results = {};
16
+ currentStatus = "idle";
17
+ latestLoad = 0;
18
+ subjectKey = "";
19
+ loading = null;
20
+ userId;
21
+ attributes;
22
+ constructor(options = {}) {
23
+ this.options = options;
24
+ this.userId = options.userId;
25
+ this.attributes = options.attributes;
26
+ }
27
+ setup(context) {
28
+ this.context = context;
29
+ context.on("identityChange", ({ userId }) => {
30
+ this.userId = userId;
31
+ this.reload();
32
+ });
33
+ context.on("consentChange", () => void this.reload());
34
+ this.reload();
35
+ return () => {
36
+ if (this.context !== context) return;
37
+ this.context = null;
38
+ this.latestLoad++;
39
+ this.loading = null;
40
+ this.subjectKey = "";
41
+ this.update({}, "idle");
42
+ };
43
+ }
44
+ get status() {
45
+ return this.currentStatus;
46
+ }
47
+ get(key, defaultValue) {
48
+ const result = this.details(key);
49
+ if (!result) return defaultValue;
50
+ const type = typeof defaultValue;
51
+ if ((type === "boolean" || type === "string" || type === "number") && typeof result.value !== type) return defaultValue;
52
+ return result.value;
53
+ }
54
+ isEnabled(key) {
55
+ return this.get(key, false);
56
+ }
57
+ details(key) {
58
+ return Object.hasOwn(this.results, key) ? this.results[key] : void 0;
59
+ }
60
+ /** Changes who flags are evaluated for and loads them again. */
61
+ setContext(context) {
62
+ if (context.userId !== void 0) this.userId = context.userId?.trim() || void 0;
63
+ if (context.attributes) this.attributes = context.attributes;
64
+ return this.reload();
65
+ }
66
+ /** Resolves once the load in flight settles. Never rejects. */
67
+ ready() {
68
+ return this.loading ?? Promise.resolve();
69
+ }
70
+ /** Loads the flags again. Never rejects; a failed load sets `status`. */
71
+ reload() {
72
+ const context = this.context;
73
+ if (!context) return Promise.resolve();
74
+ const load = ++this.latestLoad;
75
+ const subject = this.subject(context);
76
+ if (subject.key !== this.subjectKey) {
77
+ this.subjectKey = subject.key;
78
+ this.update({}, "loading");
79
+ }
80
+ const loading = this.evaluate(context, subject.body).then((results) => {
81
+ if (load === this.latestLoad) this.update(results, "ready");
82
+ }, (error) => {
83
+ if (context.debug) console.log("[FastStats] flags failed", error);
84
+ if (load !== this.latestLoad) return;
85
+ if (this.currentStatus === "loading") this.update({}, "error");
86
+ }).finally(() => {
87
+ if (this.loading === loading) this.loading = null;
88
+ });
89
+ this.loading = loading;
90
+ return loading;
91
+ }
92
+ /** Calls `listener` whenever the flags or `status` change. */
93
+ subscribe(listener) {
94
+ this.listeners.add(listener);
95
+ return () => {
96
+ this.listeners.delete(listener);
97
+ };
98
+ }
99
+ subject(context) {
100
+ const cookieless = context.isCookieless();
101
+ const { sessionId } = getSessionContext(context.siteKey, cookieless);
102
+ const identifier = getAnonymousId(context.siteKey, cookieless) || sessionId;
103
+ return {
104
+ key: JSON.stringify([identifier, this.userId]),
105
+ body: {
106
+ identifier,
107
+ userId: this.userId,
108
+ sessionId,
109
+ attributes: this.attributes ?? {},
110
+ flags: this.options.flags && [...new Set(this.options.flags)]
111
+ }
112
+ };
113
+ }
114
+ async evaluate(context, body) {
115
+ const endpoint = (this.options.endpoint ?? DEFAULT_ENDPOINT).replace(/\/+$/, "");
116
+ const response = await (this.options.fetch ?? fetch)(`${endpoint}/v1/evaluate`, {
117
+ method: "POST",
118
+ headers: {
119
+ "content-type": "application/json",
120
+ authorization: `Bearer ${context.siteKey}`
121
+ },
122
+ body: JSON.stringify(body),
123
+ signal: AbortSignal.timeout(this.options.timeoutMs ?? DEFAULT_TIMEOUT_MS),
124
+ cache: "no-store"
125
+ });
126
+ if (!response.ok) throw new Error(`Evaluation failed with status ${response.status}`);
127
+ const data = await response.json();
128
+ if (typeof data?.flags !== "object" || data.flags === null) throw new Error("Unexpected response from the flags service");
129
+ return data.flags;
130
+ }
131
+ update(results, status) {
132
+ this.results = results;
133
+ this.currentStatus = status;
134
+ for (const listener of [...this.listeners]) try {
135
+ listener();
136
+ } catch (error) {
137
+ if (this.context?.debug) console.log("[FastStats] flags listener failed", error);
138
+ }
139
+ }
140
+ };
141
+ function featureFlags(options) {
142
+ return new FeatureFlags(options);
143
+ }
144
+ //#endregion
145
+ export { FeatureFlags, featureFlags };
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { a as ExtensionHook, i as ExtensionHandler, n as AnalyticsExtensionContext, o as ExtensionHooks, r as ExtensionCleanup, t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
1
+ import { a as ExtensionHook, i as ExtensionHandler, n as AnalyticsExtensionContext, o as ExtensionHooks, r as ExtensionCleanup, t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
2
  //#region src/analytics.d.ts
3
3
  type ConsentMode = "anonymous" | "granted" | "denied";
4
4
  interface IdentifyOptions {
@@ -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,6 +66,8 @@ 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;
69
72
  private checkpoint;
70
73
  private pauseVisit;
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
- import { a as resetSession, d as getPageContext, f as resolveBaseUrl, n as createId, o as touchActivity, p as sanitizeUrl, r as getSessionContext, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
2
- import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-BwGNL11R.js";
1
+ import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, r as getPageContext, t as sendData } from "./chunks/send-data-gBIF6DTb.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";
3
4
  //#region src/extensions.ts
4
5
  var ExtensionHost = class {
5
6
  context;
@@ -39,11 +40,8 @@ var ExtensionHost = class {
39
40
  ...this.context,
40
41
  on: (hook, handler) => {
41
42
  if (this.stopped) return () => {};
42
- let handlers = this.handlers.get(hook);
43
- if (!handlers) {
44
- handlers = /* @__PURE__ */ new Set();
45
- this.handlers.set(hook, handlers);
46
- }
43
+ const handlers = this.handlers.get(hook) ?? /* @__PURE__ */ new Set();
44
+ this.handlers.set(hook, handlers);
47
45
  handlers.add(handler);
48
46
  const off = () => handlers.delete(handler);
49
47
  subscriptions.push(off);
@@ -131,6 +129,7 @@ const RESERVED_EVENTS = /* @__PURE__ */ new Set([
131
129
  "error",
132
130
  "outbound_link"
133
131
  ]);
132
+ const IDLE_MS = 6e4;
134
133
  var WebAnalytics = class {
135
134
  options;
136
135
  baseUrl;
@@ -142,6 +141,7 @@ var WebAnalytics = class {
142
141
  pageKey = "";
143
142
  visit = null;
144
143
  pendingPageview = null;
144
+ lastInputAt = 0;
145
145
  consentMode;
146
146
  constructor(options) {
147
147
  this.options = options;
@@ -169,7 +169,7 @@ var WebAnalytics = class {
169
169
  const key = location.pathname + location.search + (this.options.trackHash ? location.hash : "");
170
170
  if (key !== this.pageKey) {
171
171
  this.leavePage();
172
- if (this.pageKey) this.extensions?.emit("pageChange", { url: getPageContext(this.options.trackHash).url ?? "" });
172
+ if (this.pageKey) this.extensions?.emit("pageChange", { url: getPageContext(this.options.trackHash).url });
173
173
  this.pageKey = key;
174
174
  this.pendingPageview = null;
175
175
  }
@@ -189,6 +189,7 @@ var WebAnalytics = class {
189
189
  context,
190
190
  duration: 0,
191
191
  startedAt: null,
192
+ checkedAt: Date.now(),
192
193
  reportedDuration: -1
193
194
  };
194
195
  this.send("pageview", {
@@ -213,6 +214,7 @@ var WebAnalytics = class {
213
214
  } : userOrExternalId;
214
215
  const externalId = user.id.trim();
215
216
  if (!externalId) return false;
217
+ this.extensions?.emit("identityChange", { userId: externalId });
216
218
  const clearFields = [
217
219
  "email",
218
220
  "name",
@@ -246,6 +248,7 @@ var WebAnalytics = class {
246
248
  this.leavePage();
247
249
  if (resetAnonymousIdentity) resetAnonymousId(this.options.siteKey, this.isCookieless());
248
250
  resetSession(this.options.siteKey, this.isCookieless());
251
+ this.extensions?.emit("identityChange", { userId: void 0 });
249
252
  this.pageview({ trigger: "logout" });
250
253
  }
251
254
  setConsentMode(mode) {
@@ -323,12 +326,18 @@ var WebAnalytics = class {
323
326
  this.bind(window, "focus", this.onResume);
324
327
  this.bind(window, "blur", () => this.pauseVisit());
325
328
  this.bind(window, "popstate", this.onNavigate);
329
+ for (const type of [
330
+ "pointerdown",
331
+ "pointermove",
332
+ "keydown",
333
+ "scroll"
334
+ ]) this.bind(window, type, this.onActivity, true);
326
335
  if (this.options.trackHash) this.bind(window, "hashchange", this.onNavigate);
327
336
  this.cleanup.push(observeHistory(this.onNavigate), trackOutboundLinks((url) => this.send("outbound_link", { outbound_link: url })));
328
337
  }
329
- bind(target, type, listener) {
330
- target.addEventListener(type, listener);
331
- this.cleanup.push(() => target.removeEventListener(type, listener));
338
+ bind(target, type, listener, capture = false) {
339
+ target.addEventListener(type, listener, capture);
340
+ this.cleanup.push(() => target.removeEventListener(type, listener, capture));
332
341
  }
333
342
  onNavigate = () => this.pageview({ trigger: "navigation" });
334
343
  onVisibilityChange = () => {
@@ -362,6 +371,17 @@ var WebAnalytics = class {
362
371
  this.pageview(this.pendingPageview ?? { trigger: "session" });
363
372
  this.resumeVisit();
364
373
  };
374
+ onActivity = () => {
375
+ this.lastInputAt = performance.now();
376
+ if (this.visit?.startedAt === null && document.hasFocus()) this.onResume();
377
+ };
378
+ isIdle() {
379
+ const now = performance.now();
380
+ if (now - this.lastInputAt < IDLE_MS) return false;
381
+ const playing = [...document.querySelectorAll("video,audio")].some((media) => !media.paused && !media.ended);
382
+ if (playing) this.lastInputAt = now;
383
+ return !playing;
384
+ }
365
385
  leavePage() {
366
386
  this.pauseVisit();
367
387
  this.visit = null;
@@ -369,10 +389,16 @@ var WebAnalytics = class {
369
389
  checkpoint() {
370
390
  const visit = this.visit;
371
391
  if (!visit || !this.isActive()) return;
392
+ let activeAt = visit.checkedAt;
372
393
  if (visit.startedAt !== null) {
373
394
  const now = performance.now();
374
- visit.duration += now - visit.startedAt;
395
+ if (now - visit.startedAt < IDLE_MS) {
396
+ const active = Math.min(now, this.lastInputAt + IDLE_MS) - visit.startedAt;
397
+ visit.duration += active;
398
+ activeAt += active;
399
+ }
375
400
  visit.startedAt = now;
401
+ visit.checkedAt = Date.now();
376
402
  }
377
403
  const duration = Math.round(visit.duration);
378
404
  if (duration === visit.reportedDuration) return;
@@ -380,7 +406,7 @@ var WebAnalytics = class {
380
406
  this.send("page_leave", {
381
407
  visit_id: visit.id,
382
408
  time_on_page: duration,
383
- session_duration: Math.max(0, Date.now() - visit.session.sessionStart)
409
+ session_duration: Math.max(0, activeAt - visit.session.sessionStart)
384
410
  }, visit.context);
385
411
  }
386
412
  pauseVisit() {
@@ -391,9 +417,10 @@ var WebAnalytics = class {
391
417
  }
392
418
  resumeVisit() {
393
419
  if (!this.isActive() || !this.visit || this.visit.startedAt !== null || document.visibilityState !== "visible" || !document.hasFocus()) return;
394
- this.visit.startedAt = performance.now();
420
+ this.visit.startedAt = this.lastInputAt = performance.now();
421
+ this.visit.checkedAt = Date.now();
395
422
  this.heartbeatTimer = setInterval(() => {
396
- if (document.visibilityState !== "visible" || !document.hasFocus()) {
423
+ if (document.visibilityState !== "visible" || !document.hasFocus() || this.isIdle()) {
397
424
  this.pauseVisit();
398
425
  return;
399
426
  }
package/dist/replay.d.ts CHANGED
@@ -1,8 +1,8 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
2
  import { record } from "@rrweb/record";
3
3
  import { LogLevel } from "@rrweb/rrweb-plugin-console-record";
4
4
  import { NetworkRecordOptions } from "@rrweb/rrweb-plugin-network-record";
5
- //#region ../../node_modules/.bun/@rrweb+types@2.1.6/node_modules/@rrweb/types/dist/index.d.ts
5
+ //#region ../../node_modules/.bun/@rrweb+types@2.1.7/node_modules/@rrweb/types/dist/index.d.ts
6
6
  declare global {
7
7
  interface Window {
8
8
  FontFace: typeof FontFace;
@@ -58,7 +58,7 @@ export declare function sessionReplay(options?: SessionReplayExtensionOptions):
58
58
  export default class ReplayTracker {
59
59
  private readonly options;
60
60
  private readonly endpoint;
61
- private readonly compressionSupported;
61
+ private readonly compress;
62
62
  private readonly flushInterval;
63
63
  private readonly maxEvents;
64
64
  private readonly maxBatchSizeBytes;
@@ -95,6 +95,8 @@ export default class ReplayTracker {
95
95
  private readonly mutationThrottler;
96
96
  constructor(options: ReplayTrackerOptions);
97
97
  private log;
98
+ private sessionContext;
99
+ private sanitize;
98
100
  private clearFlushTimers;
99
101
  private dropEvents;
100
102
  private clearQueues;
@@ -131,4 +133,5 @@ export default class ReplayTracker {
131
133
  private retryDelay;
132
134
  private sendBatch;
133
135
  }
134
- //#endregion
136
+ //#endregion
137
+ export {};
package/dist/replay.js CHANGED
@@ -1,5 +1,6 @@
1
- import { f as resolveBaseUrl, i as onSessionRotated, l as setStorageItem, n as createId, o as touchActivity, p as sanitizeUrl, r as getSessionContext, s as getStorageItem, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
2
- import { t as getAnonymousId } from "./chunks/identifiers-BwGNL11R.js";
1
+ import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as sendData } from "./chunks/send-data-gBIF6DTb.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";
3
4
  import { record } from "@rrweb/record";
4
5
  import { getRecordConsolePlugin } from "@rrweb/rrweb-plugin-console-record";
5
6
  import { getRecordNetworkPlugin } from "@rrweb/rrweb-plugin-network-record";
@@ -25,7 +26,7 @@ var ReplayMutationThrottler = class {
25
26
  if (data.source !== MUTATION_SOURCE_TYPE || !data.attributes?.length) return event;
26
27
  const attributes = data.attributes.filter(({ id }) => this.consume(id));
27
28
  if (attributes.length === data.attributes.length) return event;
28
- 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;
29
30
  return {
30
31
  ...event,
31
32
  data: {
@@ -95,7 +96,8 @@ function nextReplaySequence(siteKey, context, cookieless) {
95
96
  const MAX_TIMEOUT_MS = 2147483647;
96
97
  const DEFAULT_MAX_QUEUE_SIZE_BYTES = 2097152;
97
98
  const DEFAULT_MAX_BATCH_SIZE_BYTES = 921600;
98
- const DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES = 61440;
99
+ const KEEPALIVE_LIMIT_BYTES = 61440;
100
+ const DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES = 57344;
99
101
  const RETRY_BASE_DELAY_MS = 2e3;
100
102
  const RETRY_MAX_ATTEMPTS = 5;
101
103
  const RETRY_COOLDOWN_MS = 3e5;
@@ -104,7 +106,12 @@ const RRWEB_EVENT_FULL_SNAPSHOT = 2;
104
106
  const RRWEB_EVENT_META = 4;
105
107
  const REPLAY_SEQUENTIAL_ID_KEY = "_faststatsSeqId";
106
108
  const textEncoder = new TextEncoder();
109
+ const eventSizeCache = /* @__PURE__ */ new WeakMap();
107
110
  const batchSizeCache = /* @__PURE__ */ new WeakMap();
111
+ async function gzip(json) {
112
+ const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
113
+ return new Uint8Array(await new Response(stream).arrayBuffer());
114
+ }
108
115
  function boundedInteger(value, fallback, min, max) {
109
116
  if (value === void 0 || !Number.isFinite(value) || value < min) return fallback;
110
117
  return Math.min(Math.trunc(value), max);
@@ -133,7 +140,7 @@ function sessionReplay(options = {}) {
133
140
  var ReplayTracker = class {
134
141
  options;
135
142
  endpoint;
136
- compressionSupported = typeof window !== "undefined" && "CompressionStream" in window;
143
+ compress;
137
144
  flushInterval;
138
145
  maxEvents;
139
146
  maxBatchSizeBytes;
@@ -172,7 +179,8 @@ var ReplayTracker = class {
172
179
  this.options = options;
173
180
  this.lastCookielessMode = options.cookieless ?? false;
174
181
  this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.replay}`;
175
- this.flushInterval = boundedInteger(options.flushInterval, 15e3, 1, MAX_TIMEOUT_MS);
182
+ this.compress = (options.compress ?? true) && typeof window !== "undefined" && "CompressionStream" in window;
183
+ this.flushInterval = boundedInteger(options.flushInterval, 5e3, 1, MAX_TIMEOUT_MS);
176
184
  this.maxEvents = boundedInteger(options.maxEvents, 1e3, 1, 1e5);
177
185
  this.maxBatchSizeBytes = boundedInteger(options.maxBatchSizeBytes, DEFAULT_MAX_BATCH_SIZE_BYTES, 1, 33554432);
178
186
  this.maxLowLatencyBatchSizeBytes = boundedInteger(options.maxLowLatencyBatchSizeBytes, DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES, 1, DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES);
@@ -184,6 +192,12 @@ var ReplayTracker = class {
184
192
  log(...args) {
185
193
  if (this.options.debug) console.log("[Replay]", ...args);
186
194
  }
195
+ sessionContext() {
196
+ return getSessionContext(this.options.siteKey, this.lastCookielessMode);
197
+ }
198
+ sanitize(url) {
199
+ return sanitizeUrl(url, this.options.trackHash ?? false);
200
+ }
187
201
  clearFlushTimers() {
188
202
  if (this.flushTask) clearTimeout(this.flushTask);
189
203
  if (this.retryTask) clearTimeout(this.retryTask);
@@ -214,7 +228,7 @@ var ReplayTracker = class {
214
228
  if (this.started) this.subscribeToSessionRotation();
215
229
  this.clearQueues();
216
230
  this.clearFlushTimers();
217
- const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
231
+ const context = this.sessionContext();
218
232
  this.activeSessionId = context.sessionId;
219
233
  this.resetSessionTiming(context);
220
234
  this.beginReplayStream();
@@ -227,8 +241,8 @@ var ReplayTracker = class {
227
241
  this.overflowed = false;
228
242
  this.replayEventSequence = 0;
229
243
  this.viewId = createId();
230
- this.viewUrl = sanitizeUrl(window.location.href, this.options.trackHash ?? false);
231
- const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
244
+ this.viewUrl = this.sanitize(window.location.href);
245
+ const context = this.sessionContext();
232
246
  this.activeSessionId = context.sessionId;
233
247
  this.resetSessionTiming(context);
234
248
  this.subscribeToSessionRotation();
@@ -239,7 +253,7 @@ var ReplayTracker = class {
239
253
  trackPageChange(url) {
240
254
  if (!this.started) return;
241
255
  this.viewId = createId();
242
- const href = sanitizeUrl(url ?? window.location.href, this.options.trackHash ?? false);
256
+ const href = this.sanitize(url ?? window.location.href);
243
257
  this.viewUrl = href;
244
258
  this.onEvent({
245
259
  type: VIEW_META_EVENT_TYPE,
@@ -351,12 +365,7 @@ var ReplayTracker = class {
351
365
  this.log("Recording discarded");
352
366
  return;
353
367
  }
354
- if (!this.hasReachedMinLength()) {
355
- this.dropEvents();
356
- this.needsFinalization = false;
357
- this.log(`Recording too short (${Date.now() - this.startTime}ms), discarding events`);
358
- }
359
- this.flush(true, true, "stop");
368
+ this.requestTerminalFlush("stop");
360
369
  this.log("Recording stopped");
361
370
  }
362
371
  onSessionRotated(prev, next) {
@@ -415,7 +424,7 @@ var ReplayTracker = class {
415
424
  ...event,
416
425
  data: {
417
426
  ...data,
418
- href: sanitizeUrl(data.href, this.options.trackHash ?? false)
427
+ href: this.sanitize(data.href)
419
428
  }
420
429
  };
421
430
  }
@@ -446,7 +455,8 @@ var ReplayTracker = class {
446
455
  if (!this.hasReachedMinLength()) {
447
456
  this.dropEvents();
448
457
  this.needsFinalization = false;
449
- return;
458
+ this.log(`Recording too short (${Date.now() - this.startTime}ms), discarding events`);
459
+ if (this.pending.length === 0) return;
450
460
  }
451
461
  this.flush(true, true, reason);
452
462
  }
@@ -472,12 +482,11 @@ var ReplayTracker = class {
472
482
  return;
473
483
  }
474
484
  if (this.minLengthFlushTask) return;
475
- const remainingMs = Math.max(0, this.minReplayLengthMs - (Date.now() - this.startTime));
476
485
  this.minLengthFlushTask = setTimeout(() => {
477
486
  this.minLengthFlushTask = null;
478
487
  this.initialFlushPending = false;
479
488
  this.requestFlush("minLength");
480
- }, Math.min(remainingMs, MAX_TIMEOUT_MS));
489
+ }, this.minReplayLengthMs - (Date.now() - this.startTime));
481
490
  }
482
491
  enqueueEventsIfReady(context, flushReason, maxBatchSizeBytes = this.maxBatchSizeBytes) {
483
492
  if (this.events.length === 0) return;
@@ -536,14 +545,19 @@ var ReplayTracker = class {
536
545
  sequence,
537
546
  sequenceContractVersion: 1,
538
547
  timestamp: now,
539
- url: this.viewUrl || sanitizeUrl(window.location.href, this.options.trackHash ?? false),
548
+ url: this.viewUrl || this.sanitize(window.location.href),
540
549
  ...isFinal ? { isFinal: true } : {},
541
550
  flushReason,
542
551
  events
543
552
  };
544
553
  }
545
554
  eventSizeBytes(event) {
546
- return textEncoder.encode(JSON.stringify(event)).byteLength + 1;
555
+ let size = eventSizeCache.get(event);
556
+ if (size === void 0) {
557
+ size = textEncoder.encode(JSON.stringify(event)).byteLength + 1;
558
+ eventSizeCache.set(event, size);
559
+ }
560
+ return size;
547
561
  }
548
562
  batchSizeBytes(batch) {
549
563
  const cached = batchSizeCache.get(batch);
@@ -577,7 +591,7 @@ var ReplayTracker = class {
577
591
  }
578
592
  const rotation = touchActivity(this.options.siteKey, this.lastCookielessMode);
579
593
  if (rotation) this.applySessionRotation(rotation.prev, rotation.next);
580
- const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
594
+ const context = this.sessionContext();
581
595
  this.enqueueEventsIfReady(context, flushReason, lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes);
582
596
  if (!isFinal) return this.sendPending(lowLatency);
583
597
  this.finalizeSession(context, flushReason);
@@ -629,33 +643,25 @@ var ReplayTracker = class {
629
643
  }
630
644
  async sendBatch(batch, lowLatency) {
631
645
  const json = JSON.stringify(batch);
632
- const lowLatencySafe = lowLatency && this.batchSizeBytes(batch) <= DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES;
633
- const sendOptions = {
646
+ const keepalive = lowLatency && this.batchSizeBytes(batch) <= KEEPALIVE_LIMIT_BYTES;
647
+ const send = (data, contentType, query = "") => sendData({
648
+ url: `${this.endpoint}${query}`,
649
+ data,
650
+ contentType,
634
651
  debug: this.options.debug,
635
652
  debugPrefix: "[Replay]",
636
- useBeacon: lowLatencySafe,
637
- keepalive: lowLatencySafe,
653
+ useBeacon: keepalive,
654
+ keepalive,
638
655
  settleRejected: true
639
- };
640
- if (!lowLatency && (this.options.compress ?? true) && this.compressionSupported) try {
641
- const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("gzip"));
642
- const compressed = new Uint8Array(await new Response(stream).arrayBuffer());
656
+ });
657
+ if (!lowLatency && this.compress) try {
658
+ const compressed = await gzip(json);
643
659
  this.log(`Compressed ${json.length}B -> ${compressed.byteLength}B (${Math.round(compressed.byteLength / json.length * 100)}%)`);
644
- return sendData({
645
- url: `${this.endpoint}?encoding=gzip`,
646
- data: compressed,
647
- contentType: "application/octet-stream",
648
- ...sendOptions
649
- });
660
+ return send(compressed, "application/octet-stream", "?encoding=gzip");
650
661
  } catch {
651
662
  this.log("Compression failed, using uncompressed");
652
663
  }
653
- return sendData({
654
- url: this.endpoint,
655
- data: json,
656
- contentType: "application/json",
657
- ...sendOptions
658
- });
664
+ return send(json, "application/json");
659
665
  }
660
666
  };
661
667
  //#endregion
@@ -1,4 +1,4 @@
1
- import { t as AnalyticsExtension } from "./chunks/extensions-DZDQn8O8.js";
1
+ import { t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
2
2
  //#region src/web-vitals.d.ts
3
3
  export interface WebVitalsOptions {
4
4
  siteKey: string;
@@ -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,35 +1,49 @@
1
- import { f as resolveBaseUrl, p as sanitizeUrl, r as getSessionContext, t as sendData, u as URLS } from "./chunks/send-data-D520oab1.js";
1
+ import { a as sanitizeUrl, i as resolveBaseUrl, n as URLS, t as sendData } from "./chunks/send-data-gBIF6DTb.js";
2
+ import { n as getSessionContext, r as onSessionRotated } from "./chunks/session-manager-DUpF3mi3.js";
2
3
  import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals/attribution";
3
4
  //#region src/utils/web-vitals-collector.ts
4
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
+ }
5
29
  function subscribeToVitals(subscriber) {
6
- let collector = collectors.get(document);
7
- if (!collector) {
8
- collector = {
9
- subscribers: /* @__PURE__ */ new Set(),
10
- latest: /* @__PURE__ */ new Map()
11
- };
12
- collectors.set(document, collector);
13
- const { latest, subscribers } = collector;
14
- const report = (metric) => {
15
- const snapshot = { ...metric };
16
- snapshot.attribution = { ...metric.attribution };
17
- latest.set(metric.name, snapshot);
18
- for (const listener of subscribers) listener(snapshot);
19
- };
20
- const changes = { reportAllChanges: true };
21
- onCLS(report, changes);
22
- onINP(report, changes);
23
- onLCP(report, changes);
24
- onFCP(report);
25
- onTTFB(report);
26
- }
27
- collector.subscribers.add(subscriber);
28
- 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);
29
33
  return () => {
30
- collector.subscribers.delete(subscriber);
34
+ subscribers.delete(subscriber);
31
35
  };
32
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
+ }
33
47
  //#endregion
34
48
  //#region src/web-vitals.ts
35
49
  function webVitals() {
@@ -50,13 +64,6 @@ function webVitals() {
50
64
  }
51
65
  };
52
66
  }
53
- const METRIC_NAMES = /* @__PURE__ */ new Set([
54
- "CLS",
55
- "INP",
56
- "LCP",
57
- "FCP",
58
- "TTFB"
59
- ]);
60
67
  var WebVitalsTracker = class {
61
68
  options;
62
69
  endpoint;
@@ -65,6 +72,7 @@ var WebVitalsTracker = class {
65
72
  sending;
66
73
  generation = 0;
67
74
  unsubscribe;
75
+ unfollowSession;
68
76
  sentMetrics = /* @__PURE__ */ new Map();
69
77
  initialUrl = "";
70
78
  session = null;
@@ -77,21 +85,21 @@ var WebVitalsTracker = class {
77
85
  setCookielessMode(cookieless) {
78
86
  if (this.cookieless === cookieless) return;
79
87
  this.cookieless = cookieless;
80
- this.discard();
81
- this.session = this.started ? getSessionContext(this.options.siteKey, cookieless) : null;
88
+ if (this.started) this.followSession();
82
89
  }
83
90
  start() {
84
91
  if (this.started || typeof window === "undefined") return;
85
92
  this.started = true;
86
93
  this.initialUrl = sanitizeUrl(window.performance?.getEntriesByType("navigation")[0]?.name || window.location.href, this.options.trackHash ?? false);
87
- this.session = getSessionContext(this.options.siteKey, this.cookieless);
94
+ this.followSession();
95
+ this.sentMetrics = sentVitals(`${this.endpoint}\0${this.options.siteKey}`);
88
96
  this.unsubscribe = subscribeToVitals(this.onMetric);
89
97
  }
90
98
  stop(discard = false) {
91
99
  if (!this.started) return;
92
100
  this.started = false;
93
101
  this.unsubscribe?.();
94
- this.unsubscribe = void 0;
102
+ this.unfollowSession?.();
95
103
  if (discard) this.discard();
96
104
  else this.flush();
97
105
  }
@@ -102,26 +110,39 @@ var WebVitalsTracker = class {
102
110
  if (generation === this.generation) this.flush();
103
111
  });
104
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
+ }
105
121
  onMetric = (metric) => {
106
- 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;
107
123
  this.metrics.set(metric.name, metric);
108
124
  };
109
125
  discard() {
110
126
  this.generation++;
111
127
  this.metrics.clear();
112
- this.sentMetrics.clear();
113
128
  }
114
129
  async flush() {
115
130
  const generation = this.generation;
116
131
  while (this.sending) await this.sending;
117
- if (generation !== this.generation || !this.metrics.size) return;
118
- const batch = this.metrics;
119
- 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;
120
141
  const data = JSON.stringify({
121
142
  token: this.options.siteKey,
122
143
  sessionId: session.sessionId,
123
144
  windowId: session.windowId,
124
- vitals: [...batch.values()].map(({ name, value, id, rating, navigationType, attribution }) => {
145
+ vitals: batch.map(({ name, value, id, rating, navigationType, attribution }) => {
125
146
  const previous = this.sentMetrics.get(name);
126
147
  return {
127
148
  metric: name,
@@ -136,8 +157,7 @@ var WebVitalsTracker = class {
136
157
  };
137
158
  }),
138
159
  metadata: { url: this.initialUrl }
139
- });
140
- this.metrics = /* @__PURE__ */ new Map();
160
+ }, (_key, value) => typeof value === "string" && /^https?:\/\//.test(value) ? sanitizeUrl(value, trackHash) : value);
141
161
  this.sending = sendData({
142
162
  url: this.endpoint,
143
163
  data,
@@ -146,12 +166,8 @@ var WebVitalsTracker = class {
146
166
  });
147
167
  const sent = await this.sending;
148
168
  this.sending = void 0;
149
- if (generation !== this.generation) return;
150
- for (const [name, vital] of batch) if (sent) this.sentMetrics.set(name, {
151
- id: vital.id,
152
- value: vital.value
153
- });
154
- 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);
155
171
  }
156
172
  };
157
173
  //#endregion
package/package.json CHANGED
@@ -23,6 +23,11 @@
23
23
  "import": "./dist/error.js",
24
24
  "default": "./dist/error.js"
25
25
  },
26
+ "./flags": {
27
+ "types": "./dist/flags.d.ts",
28
+ "import": "./dist/flags.js",
29
+ "default": "./dist/flags.js"
30
+ },
26
31
  "./web-vitals": {
27
32
  "types": "./dist/web-vitals.d.ts",
28
33
  "import": "./dist/web-vitals.js",
@@ -37,7 +42,7 @@
37
42
  "publishConfig": {
38
43
  "access": "public"
39
44
  },
40
- "version": "0.11.0",
45
+ "version": "0.12.1",
41
46
  "scripts": {
42
47
  "build": "tsdown && bun run check-size",
43
48
  "dev": "bun run build",
@@ -46,15 +51,15 @@
46
51
  "check-size": "node scripts/check-bundle-size.mjs"
47
52
  },
48
53
  "devDependencies": {
49
- "@rrweb/types": "^2.1.6",
54
+ "@rrweb/types": "^2.1.7",
50
55
  "@types/bun": "^1.4.2",
51
56
  "tsdown": "^0.23.0",
52
57
  "typescript": "^7.0.2"
53
58
  },
54
59
  "dependencies": {
55
- "@rrweb/record": "^2.1.6",
56
- "@rrweb/rrweb-plugin-console-record": "^2.1.6",
57
- "@rrweb/rrweb-plugin-network-record": "^2.1.6",
58
- "web-vitals": "^6.2.2"
60
+ "@rrweb/record": "^2.1.7",
61
+ "@rrweb/rrweb-plugin-console-record": "^2.1.7",
62
+ "@rrweb/rrweb-plugin-network-record": "^2.1.7",
63
+ "web-vitals": "^6.2.3"
59
64
  }
60
65
  }