@faststats/web 0.11.0 → 0.12.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
@@ -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";
@@ -77,6 +77,55 @@ since the last delivered checkpoint. The collector must accept successive events
77
77
  for the same visitor and URL; deploy the removal of its five-second debounce
78
78
  before releasing this SDK.
79
79
 
80
+ ### Feature flags
81
+
82
+ `featureFlags()` loads the project's flags for the visitor the client tracks and
83
+ resolves them synchronously from memory:
84
+
85
+ ```ts
86
+ import { init } from "@faststats/web";
87
+ import { featureFlags } from "@faststats/web/flags";
88
+
89
+ const flags = featureFlags({ flags: ["new-checkout", "max-uploads"] });
90
+ init({ siteKey: "site_123", extensions: [flags] });
91
+
92
+ await flags.ready();
93
+ if (flags.isEnabled("new-checkout")) showNewCheckout();
94
+ const maxUploads = flags.get("max-uploads", 10);
95
+ ```
96
+
97
+ Every loaded flag is recorded as an exposure, whether or not the app reads it,
98
+ so list the keys the app uses in `flags`. Without it every flag in the project
99
+ is loaded.
100
+
101
+ Flags are evaluated for the anonymous visitor ID, and the session ID links each
102
+ evaluation to analytics. `identify()` re-evaluates them for that user, so
103
+ rollouts follow the user across devices, and `logout()` goes back to a new
104
+ anonymous visitor. When the user is known at startup, pass `userId` to skip the
105
+ anonymous load. A cookieless visitor has no lasting ID, so rollouts hold for the
106
+ session only. Flags are not loaded while consent is `"denied"`.
107
+
108
+ `get()` returns the default passed at the call site while flags are loading, and
109
+ when the flag does not exist, is archived, has a different type, or the service
110
+ cannot be reached within `timeoutMs`. `details(key)` adds the variant and reason
111
+ (`rule_match`, `target_match`, `fallthrough`, `disabled`,
112
+ `prerequisite_failed`).
113
+
114
+ ```ts
115
+ flags.subscribe(() => render(flags.status)); // "idle" | "loading" | "ready" | "error"
116
+ await flags.setContext({ attributes: { plan: "pro" } });
117
+ await flags.reload();
118
+ ```
119
+
120
+ | Option | |
121
+ | ------------ | -------------------------------------------------------- |
122
+ | `flags` | The flag keys to load. |
123
+ | `userId` | Your ID for a signed-in user, when known at startup. |
124
+ | `attributes` | Anything targeting rules should match on. |
125
+ | `endpoint` | Base URL of the flags service, when self-run. |
126
+ | `timeoutMs` | Aborts a slower load. Defaults to `3000`. |
127
+ | `fetch` | A `fetch` implementation to use instead of the global. |
128
+
80
129
  ### Network recording in session replay
81
130
 
82
131
  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-CFQEWbgo.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,84 @@
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
+ //#region src/utils/send-data.ts
53
+ async function sendData(options) {
54
+ const { url, data, contentType = "application/json", debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, settleRejected } = options;
55
+ if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
56
+ const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
57
+ if (globalThis.navigator.sendBeacon(url, beaconBody)) {
58
+ if (debug) console.log(`${debugPrefix} Sent via beacon`);
59
+ return true;
60
+ }
61
+ } catch {}
62
+ const size = typeof data === "string" ? data.length : data instanceof Blob ? data.size : data.byteLength;
63
+ const post = (keepalive) => fetch(url, {
64
+ method: "POST",
65
+ body: data,
66
+ headers: { "Content-Type": contentType },
67
+ keepalive,
68
+ credentials: "omit"
69
+ });
70
+ try {
71
+ const useKeepalive = keepalive && size <= 61440;
72
+ const { ok, status } = await post(useKeepalive).catch((error) => {
73
+ if (!useKeepalive) throw error;
74
+ return post(false);
75
+ });
76
+ if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
77
+ return ok || !!settleRejected && status < 500 && status !== 429;
78
+ } catch {
79
+ if (debug) console.log(debugPrefix, "Failed to send");
80
+ return false;
81
+ }
82
+ }
83
+ //#endregion
84
+ 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 {
@@ -200,31 +149,4 @@ function onSessionRotated(siteKey, cookieless, listener) {
200
149
  };
201
150
  }
202
151
  //#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 };
152
+ 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-DYZZjeM-.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.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.0";
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-CFQEWbgo.js";
2
+ import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.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 {
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-DYZZjeM-.js";
2
+ import { a as touchActivity, i as resetSession, n as getSessionContext, t as createId } from "./chunks/session-manager-CFQEWbgo.js";
3
+ import { n as resetAnonymousId, t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
3
4
  //#region src/extensions.ts
4
5
  var ExtensionHost = class {
5
6
  context;
@@ -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) {
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;
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-DYZZjeM-.js";
2
+ import { a as touchActivity, c as setStorageItem, n as getSessionContext, o as getStorageItem, r as onSessionRotated, t as createId } from "./chunks/session-manager-CFQEWbgo.js";
3
+ import { t as getAnonymousId } from "./chunks/identifiers-CpmDmpDv.js";
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";
@@ -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
  }
@@ -536,14 +546,19 @@ var ReplayTracker = class {
536
546
  sequence,
537
547
  sequenceContractVersion: 1,
538
548
  timestamp: now,
539
- url: this.viewUrl || sanitizeUrl(window.location.href, this.options.trackHash ?? false),
549
+ url: this.viewUrl || this.sanitize(window.location.href),
540
550
  ...isFinal ? { isFinal: true } : {},
541
551
  flushReason,
542
552
  events
543
553
  };
544
554
  }
545
555
  eventSizeBytes(event) {
546
- return textEncoder.encode(JSON.stringify(event)).byteLength + 1;
556
+ let size = eventSizeCache.get(event);
557
+ if (size === void 0) {
558
+ size = textEncoder.encode(JSON.stringify(event)).byteLength + 1;
559
+ eventSizeCache.set(event, size);
560
+ }
561
+ return size;
547
562
  }
548
563
  batchSizeBytes(batch) {
549
564
  const cached = batchSizeCache.get(batch);
@@ -577,7 +592,7 @@ var ReplayTracker = class {
577
592
  }
578
593
  const rotation = touchActivity(this.options.siteKey, this.lastCookielessMode);
579
594
  if (rotation) this.applySessionRotation(rotation.prev, rotation.next);
580
- const context = getSessionContext(this.options.siteKey, this.lastCookielessMode);
595
+ const context = this.sessionContext();
581
596
  this.enqueueEventsIfReady(context, flushReason, lowLatency ? this.maxLowLatencyBatchSizeBytes : this.maxBatchSizeBytes);
582
597
  if (!isFinal) return this.sendPending(lowLatency);
583
598
  this.finalizeSession(context, flushReason);
@@ -629,33 +644,25 @@ var ReplayTracker = class {
629
644
  }
630
645
  async sendBatch(batch, lowLatency) {
631
646
  const json = JSON.stringify(batch);
632
- const lowLatencySafe = lowLatency && this.batchSizeBytes(batch) <= DEFAULT_MAX_LOW_LATENCY_BATCH_SIZE_BYTES;
633
- const sendOptions = {
647
+ const keepalive = lowLatency && this.batchSizeBytes(batch) <= KEEPALIVE_LIMIT_BYTES;
648
+ const send = (data, contentType, query = "") => sendData({
649
+ url: `${this.endpoint}${query}`,
650
+ data,
651
+ contentType,
634
652
  debug: this.options.debug,
635
653
  debugPrefix: "[Replay]",
636
- useBeacon: lowLatencySafe,
637
- keepalive: lowLatencySafe,
654
+ useBeacon: keepalive,
655
+ keepalive,
638
656
  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());
657
+ });
658
+ if (!lowLatency && this.compress) try {
659
+ const compressed = await gzip(json);
643
660
  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
- });
661
+ return send(compressed, "application/octet-stream", "?encoding=gzip");
650
662
  } catch {
651
663
  this.log("Compression failed, using uncompressed");
652
664
  }
653
- return sendData({
654
- url: this.endpoint,
655
- data: json,
656
- contentType: "application/json",
657
- ...sendOptions
658
- });
665
+ return send(json, "application/json");
659
666
  }
660
667
  };
661
668
  //#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;
@@ -1,4 +1,5 @@
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-DYZZjeM-.js";
2
+ import { n as getSessionContext } from "./chunks/session-manager-CFQEWbgo.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();
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.0",
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
  }