@faststats/web 0.2.13 → 0.3.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/dist/chunks/api-urls-nKmJnyjD.js +1 -0
- package/dist/chunks/identifiers-wylBcW5K.js +1 -0
- package/dist/chunks/{replay-BuX3_0xs.d.ts → replay-Dpl1P6cw.d.ts} +17 -12
- package/dist/chunks/send-data-D6WdryL7.js +1 -0
- package/dist/error.d.ts +4 -12
- package/dist/error.js +2 -1
- package/dist/feature-flags.d.ts +19 -2
- package/dist/feature-flags.js +1 -1
- package/dist/index.d.ts +15 -37
- package/dist/index.js +1 -1
- package/dist/replay.d.ts +2 -2
- package/dist/replay.js +1 -1
- package/dist/web-vitals.d.ts +4 -7
- package/dist/web-vitals.js +1 -1
- package/package.json +9 -11
- package/CHANGELOG.md +0 -177
- package/dist/chunks/api-urls-DaeYkG0_.js +0 -1
- package/dist/chunks/error-CDVOMiI9.js +0 -2
- package/dist/chunks/feature-flags-BClx56v5.d.ts +0 -19
- package/dist/chunks/feature-flags-DSOCIZHK.js +0 -1
- package/dist/chunks/identifiers-CQeWm7wi.js +0 -1
- package/dist/chunks/replay-CHFW2q4E.js +0 -1
- package/dist/chunks/rolldown-runtime-MP-BAFHD.js +0 -1
- package/dist/chunks/send-data-DL_GlsQw.js +0 -1
- package/dist/chunks/types-CYzR5xtT.js +0 -1
- package/dist/chunks/web-vitals-GwPlL7Zy.js +0 -1
- package/scripts/check-bundle-size.mjs +0 -96
- package/src/analytics.ts +0 -776
- package/src/entries/error.ts +0 -6
- package/src/entries/feature-flags.ts +0 -5
- package/src/entries/main.ts +0 -21
- package/src/entries/replay.ts +0 -1
- package/src/entries/web-vitals.ts +0 -1
- package/src/env.d.ts +0 -2
- package/src/error.ts +0 -252
- package/src/feature-flags.ts +0 -84
- package/src/replay.ts +0 -469
- package/src/sdk.ts +0 -8
- package/src/utils/api-urls.ts +0 -24
- package/src/utils/identifiers.ts +0 -89
- package/src/utils/send-data.ts +0 -52
- package/src/utils/types.ts +0 -15
- package/src/web-vitals.ts +0 -159
- package/tests/analytics.test.ts +0 -450
- package/tests/identifiers.test.ts +0 -95
- package/tests/replay.test.ts +0 -412
- package/tests/web-vitals.test.ts +0 -208
- package/tsconfig.json +0 -30
- package/tsdown.config.ts +0 -27
- package/worker/index.ts +0 -22
- package/wrangler.toml +0 -8
package/src/web-vitals.ts
DELETED
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import type { Metric, MetricWithAttribution } from "web-vitals";
|
|
2
|
-
import { normalizeAnalyticsBaseUrl, URLS } from "./utils/api-urls";
|
|
3
|
-
import { getOrCreateSessionId } from "./utils/identifiers";
|
|
4
|
-
import { normalizeSamplingPercentage } from "./utils/types";
|
|
5
|
-
|
|
6
|
-
export interface WebVitalsOptions {
|
|
7
|
-
siteKey: string;
|
|
8
|
-
baseUrl?: string;
|
|
9
|
-
debug?: boolean;
|
|
10
|
-
samplingPercentage?: number;
|
|
11
|
-
attribution?: boolean;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
type MetricName = "CLS" | "INP" | "LCP" | "FCP" | "TTFB";
|
|
15
|
-
type AnyMetric = Metric | MetricWithAttribution;
|
|
16
|
-
|
|
17
|
-
type MetricPayload = {
|
|
18
|
-
metric: MetricName;
|
|
19
|
-
value: number;
|
|
20
|
-
attributes: Record<string, unknown>;
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
export default class WebVitalsTracker {
|
|
24
|
-
private readonly endpoint: string;
|
|
25
|
-
private readonly sampled: boolean;
|
|
26
|
-
private readonly metrics = new Map<MetricName, MetricPayload>();
|
|
27
|
-
private started = false;
|
|
28
|
-
private url = "";
|
|
29
|
-
|
|
30
|
-
constructor(private readonly options: WebVitalsOptions) {
|
|
31
|
-
this.endpoint = `${normalizeAnalyticsBaseUrl(options.baseUrl)}${URLS.vitals}`;
|
|
32
|
-
this.sampled =
|
|
33
|
-
Math.random() * 100 <
|
|
34
|
-
normalizeSamplingPercentage(options.samplingPercentage);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
private get debug(): boolean {
|
|
38
|
-
return this.options.debug ?? false;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
private log(...args: unknown[]): void {
|
|
42
|
-
if (this.debug) console.log("[WebVitals]", ...args);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
async start(): Promise<void> {
|
|
46
|
-
if (this.started || typeof window === "undefined") return;
|
|
47
|
-
|
|
48
|
-
this.started = true;
|
|
49
|
-
this.url = window.location.href;
|
|
50
|
-
|
|
51
|
-
document.addEventListener("visibilitychange", this.onHidden, {
|
|
52
|
-
passive: true,
|
|
53
|
-
});
|
|
54
|
-
window.addEventListener("pagehide", this.onPageHide, { passive: true });
|
|
55
|
-
|
|
56
|
-
const mod = this.options.attribution
|
|
57
|
-
? await import("web-vitals/attribution")
|
|
58
|
-
: await import("web-vitals");
|
|
59
|
-
|
|
60
|
-
if (!this.started) return;
|
|
61
|
-
|
|
62
|
-
mod.onCLS(this.capture, { reportAllChanges: true });
|
|
63
|
-
mod.onINP(this.capture, { reportAllChanges: true });
|
|
64
|
-
mod.onLCP(this.capture, { reportAllChanges: true });
|
|
65
|
-
mod.onFCP(this.capture);
|
|
66
|
-
mod.onTTFB(this.capture);
|
|
67
|
-
|
|
68
|
-
this.log("Tracking started");
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
stop(): void {
|
|
72
|
-
if (!this.started || typeof window === "undefined") return;
|
|
73
|
-
|
|
74
|
-
this.started = false;
|
|
75
|
-
this.removeListeners();
|
|
76
|
-
void this.flush();
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
trackPageChange(url?: string): void {
|
|
80
|
-
if (!this.started || typeof window === "undefined") return;
|
|
81
|
-
|
|
82
|
-
const nextUrl = url ?? window.location.href;
|
|
83
|
-
if (nextUrl === this.url) return;
|
|
84
|
-
|
|
85
|
-
void this.flush();
|
|
86
|
-
this.url = nextUrl;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
private onHidden = (): void => {
|
|
90
|
-
if (document.visibilityState === "hidden") {
|
|
91
|
-
void this.flush();
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
private onPageHide = (): void => {
|
|
96
|
-
void this.flush();
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
private capture = (metric: AnyMetric): void => {
|
|
100
|
-
if (!this.started || !this.sampled) return;
|
|
101
|
-
|
|
102
|
-
const name = metric.name as MetricName;
|
|
103
|
-
const attribution = (metric as MetricWithAttribution).attribution;
|
|
104
|
-
|
|
105
|
-
this.metrics.set(name, {
|
|
106
|
-
metric: name,
|
|
107
|
-
value: metric.value,
|
|
108
|
-
attributes: {
|
|
109
|
-
id: metric.id,
|
|
110
|
-
rating: metric.rating,
|
|
111
|
-
delta: metric.delta,
|
|
112
|
-
navigationType: metric.navigationType,
|
|
113
|
-
...(attribution ?? {}),
|
|
114
|
-
},
|
|
115
|
-
});
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
private async flush(): Promise<void> {
|
|
119
|
-
if (typeof window === "undefined" || typeof fetch !== "function") return;
|
|
120
|
-
if (!this.metrics.size) return;
|
|
121
|
-
|
|
122
|
-
const vitals = Array.from(this.metrics.values());
|
|
123
|
-
const url = this.url;
|
|
124
|
-
this.metrics.clear();
|
|
125
|
-
|
|
126
|
-
try {
|
|
127
|
-
const controller = new AbortController();
|
|
128
|
-
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
|
|
129
|
-
|
|
130
|
-
const response = await fetch(this.endpoint, {
|
|
131
|
-
method: "POST",
|
|
132
|
-
body: JSON.stringify({
|
|
133
|
-
sessionId: getOrCreateSessionId(),
|
|
134
|
-
vitals,
|
|
135
|
-
metadata: { url },
|
|
136
|
-
}),
|
|
137
|
-
headers: {
|
|
138
|
-
"Content-Type": "application/json",
|
|
139
|
-
Authorization: `Bearer ${this.options.siteKey}`,
|
|
140
|
-
},
|
|
141
|
-
keepalive: true,
|
|
142
|
-
signal: controller.signal,
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
clearTimeout(timeoutId);
|
|
146
|
-
|
|
147
|
-
if (!response.ok) {
|
|
148
|
-
throw new Error(`HTTP ${response.status}`);
|
|
149
|
-
}
|
|
150
|
-
} catch (error) {
|
|
151
|
-
this.log("Failed to send metrics", error);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
private removeListeners(): void {
|
|
156
|
-
document.removeEventListener("visibilitychange", this.onHidden);
|
|
157
|
-
window.removeEventListener("pagehide", this.onPageHide);
|
|
158
|
-
}
|
|
159
|
-
}
|
package/tests/analytics.test.ts
DELETED
|
@@ -1,450 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
getInstance,
|
|
4
|
-
resolveReplayTrackerOptions,
|
|
5
|
-
sendData,
|
|
6
|
-
setConsentMode,
|
|
7
|
-
WebAnalytics,
|
|
8
|
-
} from "../src/analytics";
|
|
9
|
-
|
|
10
|
-
class MockStorage {
|
|
11
|
-
private readonly store = new Map<string, string>();
|
|
12
|
-
|
|
13
|
-
getItem(key: string): string | null {
|
|
14
|
-
return this.store.get(key) ?? null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
setItem(key: string, value: string): void {
|
|
18
|
-
this.store.set(key, value);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
removeItem(key: string): void {
|
|
22
|
-
this.store.delete(key);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
class MockNode {
|
|
27
|
-
parentNode: MockNode | null = null;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
class MockAnchorElement extends MockNode {
|
|
31
|
-
constructor(
|
|
32
|
-
public href: string,
|
|
33
|
-
public host: string,
|
|
34
|
-
) {
|
|
35
|
-
super();
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
type Listener = EventListenerOrEventListenerObject;
|
|
40
|
-
|
|
41
|
-
class MockEventTarget {
|
|
42
|
-
readonly listeners = new Map<string, Set<Listener>>();
|
|
43
|
-
|
|
44
|
-
addEventListener(type: string, listener: Listener): void {
|
|
45
|
-
const existing = this.listeners.get(type) ?? new Set<Listener>();
|
|
46
|
-
existing.add(listener);
|
|
47
|
-
this.listeners.set(type, existing);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
removeEventListener(type: string, listener: Listener): void {
|
|
51
|
-
this.listeners.get(type)?.delete(listener);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
listenerCount(type: string): number {
|
|
55
|
-
return this.listeners.get(type)?.size ?? 0;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
type FetchCall = {
|
|
60
|
-
url: string;
|
|
61
|
-
body?: string;
|
|
62
|
-
headers?: Record<string, string>;
|
|
63
|
-
};
|
|
64
|
-
|
|
65
|
-
type BrowserHarness = {
|
|
66
|
-
windowTarget: MockEventTarget;
|
|
67
|
-
documentTarget: MockEventTarget;
|
|
68
|
-
fetchCalls: FetchCall[];
|
|
69
|
-
restore(): void;
|
|
70
|
-
location: {
|
|
71
|
-
href: string;
|
|
72
|
-
pathname: string;
|
|
73
|
-
hash: string;
|
|
74
|
-
search: string;
|
|
75
|
-
};
|
|
76
|
-
history: History;
|
|
77
|
-
navigator: {
|
|
78
|
-
sendBeacon: (url: string, data?: BodyInit | null) => boolean;
|
|
79
|
-
};
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
function wait(ms: number): Promise<void> {
|
|
83
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
function setGlobal(name: keyof typeof globalThis, value: unknown): void {
|
|
87
|
-
Object.defineProperty(globalThis, name, {
|
|
88
|
-
configurable: true,
|
|
89
|
-
writable: true,
|
|
90
|
-
value,
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function setupBrowser(): BrowserHarness {
|
|
95
|
-
const originalValues = {
|
|
96
|
-
window: globalThis.window,
|
|
97
|
-
document: globalThis.document,
|
|
98
|
-
location: globalThis.location,
|
|
99
|
-
history: globalThis.history,
|
|
100
|
-
localStorage: globalThis.localStorage,
|
|
101
|
-
sessionStorage: globalThis.sessionStorage,
|
|
102
|
-
navigator: globalThis.navigator,
|
|
103
|
-
fetch: globalThis.fetch,
|
|
104
|
-
Node: globalThis.Node,
|
|
105
|
-
HTMLAnchorElement: globalThis.HTMLAnchorElement,
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
const fetchCalls: FetchCall[] = [];
|
|
109
|
-
const windowTarget = new MockEventTarget();
|
|
110
|
-
const documentTarget = new MockEventTarget();
|
|
111
|
-
const localStorage = new MockStorage();
|
|
112
|
-
const sessionStorage = new MockStorage();
|
|
113
|
-
const locationState = {
|
|
114
|
-
href: "https://example.com/",
|
|
115
|
-
pathname: "/",
|
|
116
|
-
hash: "",
|
|
117
|
-
search: "",
|
|
118
|
-
};
|
|
119
|
-
|
|
120
|
-
const updateHref = (): void => {
|
|
121
|
-
locationState.href = `https://example.com${locationState.pathname}${locationState.search}${locationState.hash}`;
|
|
122
|
-
};
|
|
123
|
-
|
|
124
|
-
const history = {
|
|
125
|
-
pushState(_data: unknown, _unused: string, url?: string | URL | null) {
|
|
126
|
-
if (typeof url === "string") {
|
|
127
|
-
const parsed = new URL(url, locationState.href);
|
|
128
|
-
locationState.pathname = parsed.pathname;
|
|
129
|
-
locationState.search = parsed.search;
|
|
130
|
-
locationState.hash = parsed.hash;
|
|
131
|
-
updateHref();
|
|
132
|
-
}
|
|
133
|
-
},
|
|
134
|
-
replaceState(_data: unknown, _unused: string, url?: string | URL | null) {
|
|
135
|
-
if (typeof url === "string") {
|
|
136
|
-
const parsed = new URL(url, locationState.href);
|
|
137
|
-
locationState.pathname = parsed.pathname;
|
|
138
|
-
locationState.search = parsed.search;
|
|
139
|
-
locationState.hash = parsed.hash;
|
|
140
|
-
updateHref();
|
|
141
|
-
}
|
|
142
|
-
},
|
|
143
|
-
} as unknown as History;
|
|
144
|
-
|
|
145
|
-
const document = {
|
|
146
|
-
addEventListener: documentTarget.addEventListener.bind(documentTarget),
|
|
147
|
-
removeEventListener:
|
|
148
|
-
documentTarget.removeEventListener.bind(documentTarget),
|
|
149
|
-
referrer: "",
|
|
150
|
-
title: "FastStats",
|
|
151
|
-
visibilityState: "visible",
|
|
152
|
-
documentElement: {
|
|
153
|
-
scrollHeight: 2000,
|
|
154
|
-
scrollTop: 0,
|
|
155
|
-
},
|
|
156
|
-
body: {
|
|
157
|
-
scrollHeight: 2000,
|
|
158
|
-
},
|
|
159
|
-
} as unknown as Document;
|
|
160
|
-
|
|
161
|
-
const windowObject = {
|
|
162
|
-
addEventListener: windowTarget.addEventListener.bind(windowTarget),
|
|
163
|
-
removeEventListener: windowTarget.removeEventListener.bind(windowTarget),
|
|
164
|
-
innerHeight: 1000,
|
|
165
|
-
scrollY: 0,
|
|
166
|
-
setTimeout,
|
|
167
|
-
clearTimeout,
|
|
168
|
-
setInterval,
|
|
169
|
-
clearInterval,
|
|
170
|
-
history,
|
|
171
|
-
location: locationState,
|
|
172
|
-
} as unknown as Window & typeof globalThis;
|
|
173
|
-
|
|
174
|
-
const navigator = {
|
|
175
|
-
sendBeacon: () => false,
|
|
176
|
-
};
|
|
177
|
-
|
|
178
|
-
setGlobal("window", windowObject);
|
|
179
|
-
setGlobal("document", document);
|
|
180
|
-
setGlobal("location", locationState as Location);
|
|
181
|
-
setGlobal("history", history);
|
|
182
|
-
setGlobal("localStorage", localStorage);
|
|
183
|
-
setGlobal("sessionStorage", sessionStorage);
|
|
184
|
-
setGlobal("navigator", navigator);
|
|
185
|
-
setGlobal("fetch", (async (
|
|
186
|
-
url: string | URL | Request,
|
|
187
|
-
init?: RequestInit,
|
|
188
|
-
): Promise<Response> => {
|
|
189
|
-
let body: string | undefined;
|
|
190
|
-
if (typeof init?.body === "string") {
|
|
191
|
-
body = init.body;
|
|
192
|
-
} else if (init?.body instanceof Blob) {
|
|
193
|
-
body = await init.body.text();
|
|
194
|
-
}
|
|
195
|
-
fetchCalls.push({
|
|
196
|
-
url: String(url),
|
|
197
|
-
body,
|
|
198
|
-
headers: init?.headers as Record<string, string> | undefined,
|
|
199
|
-
});
|
|
200
|
-
return new Response("", { status: 204 });
|
|
201
|
-
}) as typeof fetch);
|
|
202
|
-
setGlobal("Node", MockNode as unknown as typeof Node);
|
|
203
|
-
setGlobal(
|
|
204
|
-
"HTMLAnchorElement",
|
|
205
|
-
MockAnchorElement as unknown as typeof HTMLAnchorElement,
|
|
206
|
-
);
|
|
207
|
-
|
|
208
|
-
return {
|
|
209
|
-
windowTarget,
|
|
210
|
-
documentTarget,
|
|
211
|
-
fetchCalls,
|
|
212
|
-
location: locationState,
|
|
213
|
-
history,
|
|
214
|
-
navigator,
|
|
215
|
-
restore() {
|
|
216
|
-
setGlobal("window", originalValues.window);
|
|
217
|
-
setGlobal("document", originalValues.document);
|
|
218
|
-
setGlobal("location", originalValues.location);
|
|
219
|
-
setGlobal("history", originalValues.history);
|
|
220
|
-
setGlobal("localStorage", originalValues.localStorage);
|
|
221
|
-
setGlobal("sessionStorage", originalValues.sessionStorage);
|
|
222
|
-
setGlobal("navigator", originalValues.navigator);
|
|
223
|
-
setGlobal("fetch", originalValues.fetch);
|
|
224
|
-
setGlobal("Node", originalValues.Node);
|
|
225
|
-
setGlobal("HTMLAnchorElement", originalValues.HTMLAnchorElement);
|
|
226
|
-
},
|
|
227
|
-
};
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
let harness: BrowserHarness;
|
|
231
|
-
|
|
232
|
-
beforeEach(() => {
|
|
233
|
-
harness = setupBrowser();
|
|
234
|
-
});
|
|
235
|
-
|
|
236
|
-
afterEach(() => {
|
|
237
|
-
getInstance()?.destroy();
|
|
238
|
-
harness.restore();
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
describe("WebAnalytics lifecycle", () => {
|
|
242
|
-
test("start installs listeners and destroy removes them", async () => {
|
|
243
|
-
const analytics = new WebAnalytics({
|
|
244
|
-
siteKey: "site_test",
|
|
245
|
-
autoTrack: false,
|
|
246
|
-
});
|
|
247
|
-
await analytics.start();
|
|
248
|
-
|
|
249
|
-
expect(getInstance()).toBe(analytics);
|
|
250
|
-
expect(harness.windowTarget.listenerCount("pagehide")).toBe(1);
|
|
251
|
-
expect(harness.windowTarget.listenerCount("popstate")).toBe(1);
|
|
252
|
-
expect(harness.documentTarget.listenerCount("visibilitychange")).toBe(1);
|
|
253
|
-
expect(harness.documentTarget.listenerCount("click")).toBe(1);
|
|
254
|
-
|
|
255
|
-
analytics.destroy();
|
|
256
|
-
|
|
257
|
-
expect(getInstance()).toBeNull();
|
|
258
|
-
expect(harness.windowTarget.listenerCount("pagehide")).toBe(0);
|
|
259
|
-
expect(harness.windowTarget.listenerCount("popstate")).toBe(0);
|
|
260
|
-
expect(harness.documentTarget.listenerCount("visibilitychange")).toBe(0);
|
|
261
|
-
expect(harness.documentTarget.listenerCount("click")).toBe(0);
|
|
262
|
-
});
|
|
263
|
-
|
|
264
|
-
test("sendData uses beacon before fetch", async () => {
|
|
265
|
-
harness.navigator.sendBeacon = () => true;
|
|
266
|
-
const sent = await sendData({
|
|
267
|
-
url: "https://example.com/v1/web",
|
|
268
|
-
data: '{"ok":true}',
|
|
269
|
-
});
|
|
270
|
-
expect(sent).toBe(true);
|
|
271
|
-
expect(harness.fetchCalls.length).toBe(0);
|
|
272
|
-
});
|
|
273
|
-
|
|
274
|
-
test("sendData returns false when fetch is unavailable", async () => {
|
|
275
|
-
harness.navigator.sendBeacon = () => false;
|
|
276
|
-
setGlobal("fetch", undefined as unknown as typeof fetch);
|
|
277
|
-
const sent = await sendData({
|
|
278
|
-
url: "https://example.com/v1/web",
|
|
279
|
-
data: '{"ok":true}',
|
|
280
|
-
});
|
|
281
|
-
expect(sent).toBe(false);
|
|
282
|
-
});
|
|
283
|
-
|
|
284
|
-
test("sendData can skip beacon and use fetch", async () => {
|
|
285
|
-
harness.navigator.sendBeacon = () => true;
|
|
286
|
-
const sent = await sendData({
|
|
287
|
-
url: "https://example.com/v1/web",
|
|
288
|
-
data: '{"ok":true}',
|
|
289
|
-
useBeacon: false,
|
|
290
|
-
});
|
|
291
|
-
expect(sent).toBe(true);
|
|
292
|
-
expect(harness.fetchCalls.length).toBe(1);
|
|
293
|
-
});
|
|
294
|
-
|
|
295
|
-
test("module consent updates apply before instance starts", () => {
|
|
296
|
-
setConsentMode("denied");
|
|
297
|
-
const analytics = new WebAnalytics({
|
|
298
|
-
siteKey: "site_test",
|
|
299
|
-
autoTrack: false,
|
|
300
|
-
consent: { mode: "granted" },
|
|
301
|
-
});
|
|
302
|
-
expect(analytics.getConsentMode()).toBe("denied");
|
|
303
|
-
});
|
|
304
|
-
|
|
305
|
-
test("consent changes unblock identify payloads", async () => {
|
|
306
|
-
const analytics = new WebAnalytics({
|
|
307
|
-
siteKey: "site_test",
|
|
308
|
-
autoTrack: false,
|
|
309
|
-
consent: {
|
|
310
|
-
mode: "pending",
|
|
311
|
-
cookielessWhilePending: true,
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
await analytics.start();
|
|
316
|
-
analytics.identify("user_1", "user@example.com");
|
|
317
|
-
await wait(0);
|
|
318
|
-
expect(
|
|
319
|
-
harness.fetchCalls.some((call) => call.url.endsWith("/v1/identify")),
|
|
320
|
-
).toBe(false);
|
|
321
|
-
|
|
322
|
-
analytics.setConsentMode("granted");
|
|
323
|
-
const identified = await analytics.identify("user_1", "user@example.com", {
|
|
324
|
-
name: "User One",
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
const identifyCall = harness.fetchCalls.find((call) =>
|
|
328
|
-
call.url.endsWith("/v1/identify"),
|
|
329
|
-
);
|
|
330
|
-
expect(identified).toBe(true);
|
|
331
|
-
expect(identifyCall).toBeTruthy();
|
|
332
|
-
expect(identifyCall?.body).toContain('"externalId":"user_1"');
|
|
333
|
-
expect(identifyCall?.body).toContain('"email":"user@example.com"');
|
|
334
|
-
});
|
|
335
|
-
|
|
336
|
-
test("feature flag checks send identifier with external id", async () => {
|
|
337
|
-
const analytics = new WebAnalytics({
|
|
338
|
-
siteKey: "site_test",
|
|
339
|
-
autoTrack: false,
|
|
340
|
-
});
|
|
341
|
-
setGlobal("fetch", (async (
|
|
342
|
-
url: string | URL | Request,
|
|
343
|
-
init?: RequestInit,
|
|
344
|
-
): Promise<Response> => {
|
|
345
|
-
harness.fetchCalls.push({
|
|
346
|
-
url: String(url),
|
|
347
|
-
body: typeof init?.body === "string" ? init.body : undefined,
|
|
348
|
-
headers: init?.headers as Record<string, string> | undefined,
|
|
349
|
-
});
|
|
350
|
-
return Response.json({ value: "true" });
|
|
351
|
-
}) as typeof fetch);
|
|
352
|
-
|
|
353
|
-
await analytics.start();
|
|
354
|
-
await analytics.checkFeatureFlag("beta", undefined, {
|
|
355
|
-
externalId: "user_1",
|
|
356
|
-
});
|
|
357
|
-
|
|
358
|
-
const checkCall = harness.fetchCalls.find((call) =>
|
|
359
|
-
call.url.endsWith("/v1/check"),
|
|
360
|
-
);
|
|
361
|
-
expect(checkCall).toBeTruthy();
|
|
362
|
-
expect(checkCall?.body).toContain('"key":"beta"');
|
|
363
|
-
expect(checkCall?.body).toContain('"externalId":"user_1"');
|
|
364
|
-
expect(checkCall?.body).toContain('"identifier":');
|
|
365
|
-
expect(checkCall?.body).toContain('"sessionId":');
|
|
366
|
-
});
|
|
367
|
-
|
|
368
|
-
test("navigation sends page leave and navigation pageview", async () => {
|
|
369
|
-
const analytics = new WebAnalytics({
|
|
370
|
-
siteKey: "site_test",
|
|
371
|
-
autoTrack: false,
|
|
372
|
-
});
|
|
373
|
-
|
|
374
|
-
await analytics.start();
|
|
375
|
-
harness.fetchCalls.length = 0;
|
|
376
|
-
|
|
377
|
-
harness.history.pushState({}, "", "/about");
|
|
378
|
-
await wait(350);
|
|
379
|
-
await wait(0);
|
|
380
|
-
|
|
381
|
-
const payloads = harness.fetchCalls.map((call) => call.body ?? "");
|
|
382
|
-
expect(payloads.some((body) => body.includes('"event":"page_leave"'))).toBe(
|
|
383
|
-
true,
|
|
384
|
-
);
|
|
385
|
-
expect(
|
|
386
|
-
payloads.some(
|
|
387
|
-
(body) =>
|
|
388
|
-
body.includes('"event":"pageview"') &&
|
|
389
|
-
body.includes('"trigger":"navigation"') &&
|
|
390
|
-
body.includes('"page":"/about"'),
|
|
391
|
-
),
|
|
392
|
-
).toBe(true);
|
|
393
|
-
});
|
|
394
|
-
|
|
395
|
-
test("visibility hide refreshes stale session before page leave", async () => {
|
|
396
|
-
const analytics = new WebAnalytics({
|
|
397
|
-
siteKey: "site_test",
|
|
398
|
-
autoTrack: false,
|
|
399
|
-
});
|
|
400
|
-
|
|
401
|
-
await analytics.start();
|
|
402
|
-
const storage = globalThis.sessionStorage as unknown as MockStorage;
|
|
403
|
-
const sessionId = storage.getItem("session_id");
|
|
404
|
-
expect(sessionId).toBeTruthy();
|
|
405
|
-
storage.setItem("session_timestamp", "0");
|
|
406
|
-
harness.fetchCalls.length = 0;
|
|
407
|
-
|
|
408
|
-
Object.defineProperty(globalThis.document, "visibilityState", {
|
|
409
|
-
configurable: true,
|
|
410
|
-
value: "hidden",
|
|
411
|
-
});
|
|
412
|
-
for (const listener of harness.documentTarget.listeners.get(
|
|
413
|
-
"visibilitychange",
|
|
414
|
-
) ?? []) {
|
|
415
|
-
if (typeof listener === "function") {
|
|
416
|
-
listener(new Event("visibilitychange"));
|
|
417
|
-
} else {
|
|
418
|
-
listener.handleEvent(new Event("visibilitychange"));
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
await wait(0);
|
|
422
|
-
|
|
423
|
-
expect(storage.getItem("session_id")).toBe(sessionId);
|
|
424
|
-
const pageLeaveCall = harness.fetchCalls.find((call) =>
|
|
425
|
-
call.body?.includes('"event":"page_leave"'),
|
|
426
|
-
);
|
|
427
|
-
expect(pageLeaveCall?.body).toContain(`"sessionId":"${sessionId}"`);
|
|
428
|
-
});
|
|
429
|
-
|
|
430
|
-
test("resolveReplayTrackerOptions honors sessionReplays config", () => {
|
|
431
|
-
const options = resolveReplayTrackerOptions(
|
|
432
|
-
{
|
|
433
|
-
siteKey: "site_test",
|
|
434
|
-
sessionReplays: {
|
|
435
|
-
enabled: true,
|
|
436
|
-
sampling: { percentage: 37 },
|
|
437
|
-
},
|
|
438
|
-
},
|
|
439
|
-
"https://metrics.example.com",
|
|
440
|
-
true,
|
|
441
|
-
);
|
|
442
|
-
|
|
443
|
-
expect(options).toMatchObject({
|
|
444
|
-
siteKey: "site_test",
|
|
445
|
-
baseUrl: "https://metrics.example.com",
|
|
446
|
-
debug: true,
|
|
447
|
-
samplingPercentage: 37,
|
|
448
|
-
});
|
|
449
|
-
});
|
|
450
|
-
});
|
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
3
|
-
getOrCreateAnonymousId,
|
|
4
|
-
getOrCreateSessionId,
|
|
5
|
-
getSessionStart,
|
|
6
|
-
resetSessionId,
|
|
7
|
-
} from "../src/utils/identifiers";
|
|
8
|
-
|
|
9
|
-
class MockStorage {
|
|
10
|
-
private readonly data = new Map<string, string>();
|
|
11
|
-
|
|
12
|
-
getItem(key: string): string | null {
|
|
13
|
-
return this.data.get(key) ?? null;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
setItem(key: string, value: string): void {
|
|
17
|
-
this.data.set(key, value);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
removeItem(key: string): void {
|
|
21
|
-
this.data.delete(key);
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function setGlobal(name: keyof typeof globalThis, value: unknown): void {
|
|
26
|
-
Object.defineProperty(globalThis, name, {
|
|
27
|
-
configurable: true,
|
|
28
|
-
writable: true,
|
|
29
|
-
value,
|
|
30
|
-
});
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
const original = {
|
|
34
|
-
localStorage: globalThis.localStorage,
|
|
35
|
-
sessionStorage: globalThis.sessionStorage,
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
beforeEach(() => {
|
|
39
|
-
setGlobal("localStorage", new MockStorage());
|
|
40
|
-
setGlobal("sessionStorage", new MockStorage());
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
afterEach(() => {
|
|
44
|
-
setGlobal("localStorage", original.localStorage);
|
|
45
|
-
setGlobal("sessionStorage", original.sessionStorage);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
describe("identifiers", () => {
|
|
49
|
-
test("anonymous id is generated and persisted", () => {
|
|
50
|
-
const id = getOrCreateAnonymousId();
|
|
51
|
-
const second = getOrCreateAnonymousId();
|
|
52
|
-
expect(id.length).toBeGreaterThan(0);
|
|
53
|
-
expect(second).toBe(id);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("session id is recreated when timestamp is invalid", () => {
|
|
57
|
-
const storage = globalThis.sessionStorage as unknown as MockStorage;
|
|
58
|
-
storage.setItem("session_id", "old-session");
|
|
59
|
-
storage.setItem("session_timestamp", "NaN");
|
|
60
|
-
const id = getOrCreateSessionId();
|
|
61
|
-
expect(id).not.toBe("old-session");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test("session start falls back when stored value is invalid", () => {
|
|
65
|
-
const storage = globalThis.sessionStorage as unknown as MockStorage;
|
|
66
|
-
storage.setItem("session_start", "NaN");
|
|
67
|
-
const start = getSessionStart();
|
|
68
|
-
expect(Number.isNaN(start)).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
|
|
71
|
-
test("resetSessionId creates a new session id", () => {
|
|
72
|
-
const first = getOrCreateSessionId();
|
|
73
|
-
const second = resetSessionId();
|
|
74
|
-
expect(second.length).toBeGreaterThan(0);
|
|
75
|
-
expect(second).not.toBe(first);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test("storage access failures disable persisted identifiers", () => {
|
|
79
|
-
Object.defineProperty(globalThis, "localStorage", {
|
|
80
|
-
configurable: true,
|
|
81
|
-
get() {
|
|
82
|
-
throw new Error("storage blocked");
|
|
83
|
-
},
|
|
84
|
-
});
|
|
85
|
-
Object.defineProperty(globalThis, "sessionStorage", {
|
|
86
|
-
configurable: true,
|
|
87
|
-
get() {
|
|
88
|
-
throw new Error("storage blocked");
|
|
89
|
-
},
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
expect(getOrCreateAnonymousId()).toBe("");
|
|
93
|
-
expect(getOrCreateSessionId()).toBe("");
|
|
94
|
-
});
|
|
95
|
-
});
|