@faststats/web 0.10.3 → 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 +54 -6
- package/dist/chunks/{extensions-DW7tJY0T.d.ts → extensions-BfubKIzk.d.ts} +4 -11
- package/dist/chunks/{identifiers-BgGtAQdp.js → identifiers-CpmDmpDv.js} +1 -1
- package/dist/chunks/send-data-DYZZjeM-.js +84 -0
- package/dist/chunks/{send-data-DAVUdfNm.js → session-manager-CFQEWbgo.js} +5 -35
- package/dist/error.d.ts +5 -2
- package/dist/error.js +47 -32
- package/dist/flags.d.ts +79 -0
- package/dist/flags.js +145 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +84 -50
- package/dist/replay.d.ts +6 -4
- package/dist/replay.js +86 -97
- package/dist/web-vitals.d.ts +1 -1
- package/dist/web-vitals.js +5 -7
- package/package.json +11 -11
- package/dist/chunks/api-urls-DWk43fu6.js +0 -53
- package/dist/outbound-links.d.ts +0 -9
- package/dist/outbound-links.js +0 -62
package/README.md
CHANGED
|
@@ -3,21 +3,20 @@
|
|
|
3
3
|
> [!IMPORTANT]
|
|
4
4
|
> This repository is in early development and not intended for production use.
|
|
5
5
|
|
|
6
|
-
The base SDK
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
The base SDK handles analytics, identity, sessions, navigation, outbound link
|
|
7
|
+
clicks, and consent.
|
|
8
|
+
Error tracking, Web Vitals, session replay, and feature flags are independent
|
|
9
|
+
extensions, so applications only download the products they use.
|
|
9
10
|
|
|
10
11
|
```ts
|
|
11
12
|
import { init, track } from "@faststats/web";
|
|
12
13
|
import { errorTracking } from "@faststats/web/error";
|
|
13
|
-
import { outboundLinks } from "@faststats/web/outbound-links";
|
|
14
14
|
import { sessionReplay } from "@faststats/web/replay";
|
|
15
15
|
import { webVitals } from "@faststats/web/web-vitals";
|
|
16
16
|
|
|
17
17
|
init({
|
|
18
18
|
siteKey: "site_123",
|
|
19
19
|
extensions: [
|
|
20
|
-
outboundLinks(),
|
|
21
20
|
errorTracking(),
|
|
22
21
|
webVitals(),
|
|
23
22
|
sessionReplay({ recordConsole: true }),
|
|
@@ -78,6 +77,55 @@ since the last delivered checkpoint. The collector must accept successive events
|
|
|
78
77
|
for the same visitor and URL; deploy the removal of its five-second debounce
|
|
79
78
|
before releasing this SDK.
|
|
80
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
|
+
|
|
81
129
|
### Network recording in session replay
|
|
82
130
|
|
|
83
131
|
Network recording is disabled by default. Enable it with
|
|
@@ -123,7 +171,7 @@ export function diagnostics(): AnalyticsExtension {
|
|
|
123
171
|
name: "diagnostics",
|
|
124
172
|
setup(context) {
|
|
125
173
|
const off = context.on("pageChange", ({ url }) => {
|
|
126
|
-
console.debug("page changed", url
|
|
174
|
+
console.debug("page changed", url);
|
|
127
175
|
});
|
|
128
176
|
|
|
129
177
|
return () => off();
|
|
@@ -1,10 +1,3 @@
|
|
|
1
|
-
//#region src/utils/session-manager.d.ts
|
|
2
|
-
type SessionContext = {
|
|
3
|
-
sessionId: string;
|
|
4
|
-
windowId: string;
|
|
5
|
-
sessionStart: number;
|
|
6
|
-
};
|
|
7
|
-
//#endregion
|
|
8
1
|
//#region src/extensions.d.ts
|
|
9
2
|
type ExtensionHooks = {
|
|
10
3
|
pageChange: {
|
|
@@ -20,6 +13,10 @@ type ExtensionHooks = {
|
|
|
20
13
|
consentChange: {
|
|
21
14
|
cookieless: boolean;
|
|
22
15
|
};
|
|
16
|
+
/** `identify()` set a user, or `logout()` cleared one. */
|
|
17
|
+
identityChange: {
|
|
18
|
+
userId: string | undefined;
|
|
19
|
+
};
|
|
23
20
|
error: {
|
|
24
21
|
error: Error;
|
|
25
22
|
};
|
|
@@ -34,11 +31,7 @@ interface AnalyticsExtensionContext {
|
|
|
34
31
|
readonly sdkName?: string;
|
|
35
32
|
readonly sdkVersion?: string;
|
|
36
33
|
isCookieless(): boolean;
|
|
37
|
-
getAnonymousId(): string;
|
|
38
|
-
getSession(): SessionContext;
|
|
39
|
-
capture(event: string, properties?: Record<string, unknown>): void;
|
|
40
34
|
on<K extends ExtensionHook>(hook: K, handler: ExtensionHandler<K>): () => void;
|
|
41
|
-
log(message: string, ...details: unknown[]): void;
|
|
42
35
|
}
|
|
43
36
|
interface AnalyticsExtension {
|
|
44
37
|
readonly name: string;
|
|
@@ -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 };
|
|
@@ -31,6 +31,7 @@ function removeStorageItem(name, key) {
|
|
|
31
31
|
const IDLE_MS = 18e5;
|
|
32
32
|
const MAX_AGE_MS = 864e5;
|
|
33
33
|
const SESSION_KEY = "faststats_session_";
|
|
34
|
+
const WINDOW_KEY = "faststats_window_id_";
|
|
34
35
|
const NO_STORAGE = {};
|
|
35
36
|
const memorySessions = /* @__PURE__ */ new Map();
|
|
36
37
|
const rotationListeners = /* @__PURE__ */ new Map();
|
|
@@ -64,7 +65,7 @@ function readSession(siteKey) {
|
|
|
64
65
|
if (!raw) return memory;
|
|
65
66
|
try {
|
|
66
67
|
const session = JSON.parse(raw);
|
|
67
|
-
|
|
68
|
+
if (typeof session?.id === "string" && session.id && Number.isFinite(session.start) && Number.isFinite(session.activity)) return session;
|
|
68
69
|
} catch {}
|
|
69
70
|
return memory;
|
|
70
71
|
}
|
|
@@ -104,7 +105,7 @@ function resolveSession(siteKey, cookieless, touch) {
|
|
|
104
105
|
}
|
|
105
106
|
function windowId(siteKey, cookieless, sessionId) {
|
|
106
107
|
if (cookieless) return sessionId;
|
|
107
|
-
const key =
|
|
108
|
+
const key = `${WINDOW_KEY}${siteKey}`;
|
|
108
109
|
const existing = getStorageItem("sessionStorage", key);
|
|
109
110
|
if (existing) return existing;
|
|
110
111
|
const id = createId();
|
|
@@ -128,7 +129,7 @@ function resetSession(siteKey, cookieless = false) {
|
|
|
128
129
|
memorySessions.delete(memoryKey(siteKey, cookieless));
|
|
129
130
|
if (!cookieless) {
|
|
130
131
|
removeStorageItem("localStorage", `${SESSION_KEY}${siteKey}`);
|
|
131
|
-
removeStorageItem("sessionStorage",
|
|
132
|
+
removeStorageItem("sessionStorage", `${WINDOW_KEY}${siteKey}`);
|
|
132
133
|
}
|
|
133
134
|
const next = getSessionContext(siteKey, cookieless);
|
|
134
135
|
if (previous.sessionId !== next.sessionId) emitRotation(siteKey, cookieless, previous, next);
|
|
@@ -148,35 +149,4 @@ function onSessionRotated(siteKey, cookieless, listener) {
|
|
|
148
149
|
};
|
|
149
150
|
}
|
|
150
151
|
//#endregion
|
|
151
|
-
|
|
152
|
-
async function sendData(options) {
|
|
153
|
-
const { url, data, contentType = "application/json", headers = {}, debug = false, debugPrefix = "[Analytics]", useBeacon = true, keepalive = true, signal, settleRejected } = options;
|
|
154
|
-
if (signal?.aborted) return false;
|
|
155
|
-
if (useBeacon && typeof globalThis.navigator?.sendBeacon === "function") try {
|
|
156
|
-
const beaconBody = data instanceof Blob || typeof Blob === "undefined" ? data : new Blob([data], { type: contentType });
|
|
157
|
-
if (globalThis.navigator.sendBeacon(url, beaconBody)) {
|
|
158
|
-
if (debug) console.log(`${debugPrefix} Sent via beacon`);
|
|
159
|
-
return true;
|
|
160
|
-
}
|
|
161
|
-
} catch {}
|
|
162
|
-
try {
|
|
163
|
-
const { ok, status } = await fetch(url, {
|
|
164
|
-
method: "POST",
|
|
165
|
-
body: data,
|
|
166
|
-
headers: {
|
|
167
|
-
"Content-Type": contentType,
|
|
168
|
-
...headers
|
|
169
|
-
},
|
|
170
|
-
keepalive,
|
|
171
|
-
credentials: "omit",
|
|
172
|
-
signal
|
|
173
|
-
});
|
|
174
|
-
if (debug) console.log(debugPrefix, ok ? "Sent via fetch" : status);
|
|
175
|
-
return ok || !!settleRejected && status < 500 && status !== 429;
|
|
176
|
-
} catch {
|
|
177
|
-
if (debug) console.log(debugPrefix, "Failed to send");
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
//#endregion
|
|
182
|
-
export { resetSession as a, removeStorageItem as c, onSessionRotated as i, setStorageItem as l, createId as n, touchActivity as o, getSessionContext as r, getStorageItem as s, sendData as t };
|
|
152
|
+
export { touchActivity as a, setStorageItem as c, resetSession as i, getSessionContext as n, getStorageItem as o, onSessionRotated as r, removeStorageItem as s, createId as t };
|
package/dist/error.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as AnalyticsExtension } from "./chunks/extensions-
|
|
1
|
+
import { t as AnalyticsExtension } from "./chunks/extensions-BfubKIzk.js";
|
|
2
2
|
//#region src/error.d.ts
|
|
3
3
|
export type ErrorEntry = {
|
|
4
4
|
error: string;
|
|
@@ -17,7 +17,7 @@ export interface ErrorTrackingOptions {
|
|
|
17
17
|
sdkName?: string;
|
|
18
18
|
sdkVersion?: string;
|
|
19
19
|
}
|
|
20
|
-
export type ErrorTrackingExtensionOptions =
|
|
20
|
+
export type ErrorTrackingExtensionOptions = Pick<ErrorTrackingOptions, "flushInterval" | "maxQueueSize">;
|
|
21
21
|
export declare function errorTracking(options?: ErrorTrackingExtensionOptions): AnalyticsExtension;
|
|
22
22
|
export default class ErrorTracker {
|
|
23
23
|
private readonly options;
|
|
@@ -26,11 +26,14 @@ export default class ErrorTracker {
|
|
|
26
26
|
private timer;
|
|
27
27
|
private started;
|
|
28
28
|
private flushing;
|
|
29
|
+
private generation;
|
|
29
30
|
private cookieless;
|
|
31
|
+
private readonly maxQueueSize;
|
|
30
32
|
constructor(options: ErrorTrackingOptions);
|
|
31
33
|
setCookielessMode(cookieless: boolean): void;
|
|
32
34
|
start(): void;
|
|
33
35
|
stop(discard?: boolean): void;
|
|
36
|
+
private discard;
|
|
34
37
|
onPageHidden(persisted?: boolean): void;
|
|
35
38
|
captureError(error: Error): void;
|
|
36
39
|
private onError;
|
package/dist/error.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { i as resolveBaseUrl, n as URLS, r as getPageContext, t as
|
|
2
|
-
import {
|
|
3
|
-
import { t as getAnonymousId } from "./chunks/identifiers-
|
|
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";
|
|
4
4
|
//#region src/error.ts
|
|
5
5
|
const SDK_NAME = "@faststats/web";
|
|
6
|
-
const SDK_VERSION = "0.
|
|
6
|
+
const SDK_VERSION = "0.12.0";
|
|
7
7
|
function errorTracking(options = {}) {
|
|
8
8
|
return {
|
|
9
9
|
name: "error-tracking",
|
|
@@ -51,14 +51,17 @@ var ErrorTracker = class {
|
|
|
51
51
|
timer = null;
|
|
52
52
|
started = false;
|
|
53
53
|
flushing = false;
|
|
54
|
+
generation = 0;
|
|
54
55
|
cookieless;
|
|
56
|
+
maxQueueSize;
|
|
55
57
|
constructor(options) {
|
|
56
58
|
this.options = options;
|
|
57
|
-
this.
|
|
59
|
+
this.maxQueueSize = options.maxQueueSize ?? 50;
|
|
60
|
+
this.endpoint = `${resolveBaseUrl(options.baseUrl)}${URLS.events}`;
|
|
58
61
|
this.cookieless = options.cookieless ?? false;
|
|
59
62
|
}
|
|
60
63
|
setCookielessMode(cookieless) {
|
|
61
|
-
if (this.cookieless !== cookieless) this.
|
|
64
|
+
if (this.cookieless !== cookieless) this.discard();
|
|
62
65
|
this.cookieless = cookieless;
|
|
63
66
|
}
|
|
64
67
|
start() {
|
|
@@ -69,15 +72,19 @@ var ErrorTracker = class {
|
|
|
69
72
|
this.timer = setInterval(() => void this.flush(), this.options.flushInterval ?? 5e3);
|
|
70
73
|
}
|
|
71
74
|
stop(discard = false) {
|
|
72
|
-
if (!this.started
|
|
75
|
+
if (!this.started) return;
|
|
73
76
|
this.started = false;
|
|
74
77
|
window.removeEventListener("error", this.onError);
|
|
75
78
|
window.removeEventListener("unhandledrejection", this.onRejection);
|
|
76
79
|
if (this.timer) clearInterval(this.timer);
|
|
77
80
|
this.timer = null;
|
|
78
|
-
if (discard) this.
|
|
81
|
+
if (discard) this.discard();
|
|
79
82
|
else this.flush();
|
|
80
83
|
}
|
|
84
|
+
discard() {
|
|
85
|
+
this.generation++;
|
|
86
|
+
this.queue = [];
|
|
87
|
+
}
|
|
81
88
|
onPageHidden(persisted = false) {
|
|
82
89
|
if (!persisted) this.flush();
|
|
83
90
|
}
|
|
@@ -111,11 +118,12 @@ var ErrorTracker = class {
|
|
|
111
118
|
properties: {}
|
|
112
119
|
}
|
|
113
120
|
});
|
|
114
|
-
if (this.queue.length >=
|
|
121
|
+
if (this.queue.length >= this.maxQueueSize) this.flush();
|
|
115
122
|
}
|
|
116
123
|
async flush() {
|
|
117
124
|
if (this.flushing || !this.queue.length) return;
|
|
118
125
|
this.flushing = true;
|
|
126
|
+
const generation = this.generation;
|
|
119
127
|
const batch = this.queue;
|
|
120
128
|
this.queue = [];
|
|
121
129
|
const groups = /* @__PURE__ */ new Map();
|
|
@@ -128,29 +136,36 @@ var ErrorTracker = class {
|
|
|
128
136
|
group.items.push(item);
|
|
129
137
|
groups.set(key, group);
|
|
130
138
|
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
139
|
+
let failed = [];
|
|
140
|
+
try {
|
|
141
|
+
failed = (await Promise.all([...groups.values()].map(async (group) => {
|
|
142
|
+
const errors = /* @__PURE__ */ new Map();
|
|
143
|
+
for (const { error } of group.items) {
|
|
144
|
+
const key = JSON.stringify(error);
|
|
145
|
+
const existing = errors.get(key);
|
|
146
|
+
if (existing) existing.count++;
|
|
147
|
+
else errors.set(key, {
|
|
148
|
+
...error,
|
|
149
|
+
count: 1
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
return await sendData({
|
|
153
|
+
url: this.endpoint,
|
|
154
|
+
data: JSON.stringify({
|
|
155
|
+
...group.context,
|
|
156
|
+
errors: [...errors.values()]
|
|
157
|
+
}),
|
|
158
|
+
debug: this.options.debug,
|
|
159
|
+
debugPrefix: "[ErrorTracker]",
|
|
160
|
+
settleRejected: true
|
|
161
|
+
}) ? [] : group.items;
|
|
162
|
+
}))).flat();
|
|
163
|
+
} finally {
|
|
164
|
+
this.flushing = false;
|
|
165
|
+
}
|
|
166
|
+
if (generation !== this.generation) return;
|
|
167
|
+
this.queue = [...failed, ...this.queue].slice(-this.maxQueueSize);
|
|
168
|
+
if (!this.started && !failed.length) this.flush();
|
|
154
169
|
}
|
|
155
170
|
};
|
|
156
171
|
//#endregion
|
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 };
|