@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 +59 -7
- package/dist/chunks/{extensions-DZDQn8O8.d.ts → extensions-BfubKIzk.d.ts} +4 -0
- package/dist/chunks/{identifiers-BwGNL11R.js → identifiers-DOtve3E5.js} +1 -1
- package/dist/chunks/send-data-gBIF6DTb.js +83 -0
- package/dist/chunks/{send-data-D520oab1.js → session-manager-DUpF3mi3.js} +5 -86
- package/dist/error.d.ts +1 -1
- package/dist/error.js +4 -3
- package/dist/flags.d.ts +79 -0
- package/dist/flags.js +145 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +42 -15
- package/dist/replay.d.ts +7 -4
- package/dist/replay.js +49 -43
- package/dist/web-vitals.d.ts +4 -2
- package/dist/web-vitals.js +66 -50
- package/package.json +11 -6
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,
|
|
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.
|
|
56
|
-
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { c as
|
|
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
|
-
|
|
192
|
-
|
|
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
|
|
199
|
-
if (listeners
|
|
144
|
+
listeners.delete(listener);
|
|
145
|
+
if (listeners.size === 0) rotationListeners.delete(key);
|
|
200
146
|
};
|
|
201
147
|
}
|
|
202
148
|
//#endregion
|
|
203
|
-
|
|
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
package/dist/error.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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.
|
|
6
|
+
const SDK_VERSION = "0.12.1";
|
|
6
7
|
function errorTracking(options = {}) {
|
|
7
8
|
return {
|
|
8
9
|
name: "error-tracking",
|
package/dist/flags.d.ts
ADDED
|
@@ -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-
|
|
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
|
|
2
|
-
import { n as
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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,
|
|
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-
|
|
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.
|
|
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
|
|
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 {
|
|
2
|
-
import { t as
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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 =
|
|
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 =
|
|
231
|
-
const context =
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
},
|
|
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 ||
|
|
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
|
-
|
|
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 =
|
|
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
|
|
633
|
-
const
|
|
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:
|
|
637
|
-
keepalive
|
|
653
|
+
useBeacon: keepalive,
|
|
654
|
+
keepalive,
|
|
638
655
|
settleRejected: true
|
|
639
|
-
};
|
|
640
|
-
if (!lowLatency &&
|
|
641
|
-
const
|
|
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
|
|
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
|
|
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
|
package/dist/web-vitals.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as AnalyticsExtension } from "./chunks/extensions-
|
|
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
|
|
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;
|
package/dist/web-vitals.js
CHANGED
|
@@ -1,35 +1,49 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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 || !
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
const
|
|
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:
|
|
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 (
|
|
150
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
56
|
-
"@rrweb/rrweb-plugin-console-record": "^2.1.
|
|
57
|
-
"@rrweb/rrweb-plugin-network-record": "^2.1.
|
|
58
|
-
"web-vitals": "^6.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
|
}
|