@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 +51 -2
- package/dist/chunks/{extensions-DZDQn8O8.d.ts → extensions-BfubKIzk.d.ts} +4 -0
- package/dist/chunks/{identifiers-BwGNL11R.js → identifiers-CpmDmpDv.js} +1 -1
- package/dist/chunks/send-data-DYZZjeM-.js +84 -0
- package/dist/chunks/{send-data-D520oab1.js → session-manager-CFQEWbgo.js} +1 -79
- 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 +1 -1
- package/dist/index.js +5 -2
- package/dist/replay.d.ts +5 -3
- package/dist/replay.js +47 -40
- package/dist/web-vitals.d.ts +1 -1
- package/dist/web-vitals.js +2 -1
- 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";
|
|
@@ -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
|
|
@@ -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-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
|
-
|
|
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
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-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.
|
|
6
|
+
const SDK_VERSION = "0.12.0";
|
|
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-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-
|
|
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
|
|
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-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-
|
|
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;
|
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-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
|
|
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
|
}
|
|
@@ -536,14 +546,19 @@ var ReplayTracker = class {
|
|
|
536
546
|
sequence,
|
|
537
547
|
sequenceContractVersion: 1,
|
|
538
548
|
timestamp: now,
|
|
539
|
-
url: this.viewUrl ||
|
|
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
|
-
|
|
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 =
|
|
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
|
|
633
|
-
const
|
|
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:
|
|
637
|
-
keepalive
|
|
654
|
+
useBeacon: keepalive,
|
|
655
|
+
keepalive,
|
|
638
656
|
settleRejected: true
|
|
639
|
-
};
|
|
640
|
-
if (!lowLatency &&
|
|
641
|
-
const
|
|
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
|
|
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
|
|
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
|
package/dist/web-vitals.d.ts
CHANGED
package/dist/web-vitals.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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.
|
|
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.
|
|
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
|
}
|