@squasher-ai/browser 0.1.1 → 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/LICENSE +21 -21
- package/README.md +3 -42
- package/dist/_vendor/result-runtime/attempt.d.ts +17 -0
- package/dist/_vendor/result-runtime/attempt.js +62 -0
- package/dist/_vendor/sdk-runtime/batching/index.d.ts +42 -0
- package/dist/_vendor/sdk-runtime/batching/index.js +48 -0
- package/dist/_vendor/sdk-runtime/errors/headers.d.ts +6 -0
- package/dist/_vendor/sdk-runtime/errors/headers.js +44 -0
- package/dist/_vendor/sdk-runtime/errors/index.d.ts +95 -0
- package/dist/_vendor/sdk-runtime/errors/index.js +157 -0
- package/dist/_vendor/sdk-runtime/headers.d.ts +48 -0
- package/dist/_vendor/sdk-runtime/headers.js +67 -0
- package/dist/_vendor/sdk-runtime/platform.d.ts +33 -0
- package/dist/_vendor/sdk-runtime/platform.js +173 -0
- package/dist/_vendor/sdk-runtime/retry.d.ts +50 -0
- package/dist/_vendor/sdk-runtime/retry.js +104 -0
- package/dist/_vendor/sdk-runtime/runtime/actionable-error.d.ts +37 -0
- package/dist/_vendor/sdk-runtime/runtime/actionable-error.js +123 -0
- package/dist/_vendor/sdk-runtime/runtime/environment.d.ts +18 -0
- package/dist/_vendor/sdk-runtime/runtime/environment.js +81 -0
- package/dist/_vendor/sdk-runtime/runtime/public-sdk-runtime.d.ts +12 -0
- package/dist/_vendor/sdk-runtime/runtime/public-sdk-runtime.js +38 -0
- package/dist/_vendor/sdk-runtime/runtime/redaction.d.ts +14 -0
- package/dist/_vendor/sdk-runtime/runtime/redaction.js +120 -0
- package/dist/_vendor/sdk-runtime/runtime/release.d.ts +35 -0
- package/dist/_vendor/sdk-runtime/runtime/release.js +120 -0
- package/dist/_vendor/sdk-runtime/sampling/index.d.ts +43 -0
- package/dist/_vendor/sdk-runtime/sampling/index.js +70 -0
- package/dist/_vendor/telemetry-contract/sdk/public-contract.d.ts +175 -0
- package/dist/_vendor/telemetry-contract/sdk/public-contract.js +5 -0
- package/dist/autocapture.d.ts +14 -19
- package/dist/autocapture.js +77 -95
- package/dist/client.d.ts +12 -20
- package/dist/client.js +56 -72
- package/dist/config.d.ts +24 -0
- package/dist/config.js +26 -0
- package/dist/console-capture.d.ts +7 -0
- package/dist/console-capture.js +79 -0
- package/dist/errors.d.ts +2 -3
- package/dist/errors.js +8 -5
- package/dist/index.d.ts +5 -39
- package/dist/index.js +4 -82
- package/dist/next.d.ts +39 -0
- package/dist/next.js +67 -0
- package/dist/react.d.ts +0 -1
- package/dist/react.js +12 -10
- package/dist/replay-network.d.ts +20 -0
- package/dist/replay-network.js +193 -0
- package/dist/replay-privacy.d.ts +2 -3
- package/dist/replay-privacy.js +47 -17
- package/dist/replay-retry.d.ts +40 -0
- package/dist/replay-retry.js +193 -0
- package/dist/replay.d.ts +18 -6
- package/dist/replay.js +131 -71
- package/dist/runtime/client-singleton.d.ts +24 -0
- package/dist/runtime/client-singleton.js +66 -0
- package/dist/runtime/client.d.ts +21 -0
- package/dist/runtime/client.js +12 -0
- package/dist/runtime/replay.d.ts +12 -0
- package/dist/runtime/replay.js +15 -0
- package/dist/session.d.ts +0 -11
- package/dist/session.js +20 -21
- package/dist/telemetry.d.ts +1 -2
- package/dist/telemetry.js +13 -10
- package/dist/trace-context.d.ts +8 -0
- package/dist/trace-context.js +13 -0
- package/dist/transport.d.ts +11 -20
- package/dist/transport.js +86 -66
- package/dist/types.d.ts +38 -139
- package/dist/types.js +0 -7
- package/dist/vitals.d.ts +55 -3
- package/dist/vitals.js +115 -16
- package/package.json +11 -3
- package/dist/__tests__/client.test.d.ts +0 -2
- package/dist/__tests__/client.test.d.ts.map +0 -1
- package/dist/__tests__/client.test.js +0 -103
- package/dist/__tests__/errors.test.d.ts +0 -2
- package/dist/__tests__/errors.test.d.ts.map +0 -1
- package/dist/__tests__/errors.test.js +0 -80
- package/dist/__tests__/replay-privacy.test.d.ts +0 -2
- package/dist/__tests__/replay-privacy.test.d.ts.map +0 -1
- package/dist/__tests__/replay-privacy.test.js +0 -124
- package/dist/__tests__/replay-startup.test.d.ts +0 -2
- package/dist/__tests__/replay-startup.test.d.ts.map +0 -1
- package/dist/__tests__/replay-startup.test.js +0 -99
- package/dist/__tests__/replay.test.d.ts +0 -2
- package/dist/__tests__/replay.test.d.ts.map +0 -1
- package/dist/__tests__/replay.test.js +0 -217
- package/dist/__tests__/session.test.d.ts +0 -2
- package/dist/__tests__/session.test.d.ts.map +0 -1
- package/dist/__tests__/session.test.js +0 -46
- package/dist/__tests__/transport.test.d.ts +0 -2
- package/dist/__tests__/transport.test.d.ts.map +0 -1
- package/dist/__tests__/transport.test.js +0 -101
- package/dist/__tests__/types.test.d.ts +0 -2
- package/dist/__tests__/types.test.d.ts.map +0 -1
- package/dist/__tests__/types.test.js +0 -139
- package/dist/autocapture.d.ts.map +0 -1
- package/dist/client.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/replay-privacy.d.ts.map +0 -1
- package/dist/replay.d.ts.map +0 -1
- package/dist/session.d.ts.map +0 -1
- package/dist/telemetry.d.ts.map +0 -1
- package/dist/transport.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/vitals.d.ts.map +0 -1
package/dist/vitals.js
CHANGED
|
@@ -12,15 +12,15 @@
|
|
|
12
12
|
* Measurements are mapped to VitalEvent and queued via the transport.
|
|
13
13
|
*/
|
|
14
14
|
import { onLCP, onCLS, onINP, onFCP, onTTFB } from "web-vitals";
|
|
15
|
-
import { getSessionId } from "./session";
|
|
15
|
+
import { getSessionId } from "./session.js";
|
|
16
16
|
/**
|
|
17
17
|
* Infer device type from screen width.
|
|
18
18
|
* Mobile < 768px, Tablet < 1024px, Desktop >= 1024px.
|
|
19
19
|
*/
|
|
20
20
|
function inferDeviceType() {
|
|
21
|
-
if (
|
|
21
|
+
if (!globalThis.screen)
|
|
22
22
|
return undefined;
|
|
23
|
-
const width = screen.width;
|
|
23
|
+
const width = globalThis.screen.width;
|
|
24
24
|
if (width < 768)
|
|
25
25
|
return "mobile";
|
|
26
26
|
if (width < 1024)
|
|
@@ -32,13 +32,54 @@ function inferDeviceType() {
|
|
|
32
32
|
* Returns undefined if not supported (Firefox, Safari).
|
|
33
33
|
*/
|
|
34
34
|
function getConnectionType() {
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
return globalThis.navigator?.connection?.effectiveType;
|
|
36
|
+
}
|
|
37
|
+
function getBrowserTimezone() {
|
|
38
|
+
try {
|
|
39
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function getUrlDimensions() {
|
|
46
|
+
if (!globalThis.location) {
|
|
47
|
+
return {
|
|
48
|
+
page_path: "",
|
|
49
|
+
query_string: "",
|
|
50
|
+
url: "",
|
|
51
|
+
utm_campaign: undefined,
|
|
52
|
+
utm_medium: undefined,
|
|
53
|
+
utm_source: undefined,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const href = globalThis.location.href;
|
|
57
|
+
const parsed = href ? safeUrl(href) : undefined;
|
|
58
|
+
const rawSearch = globalThis.location.search || parsed?.search || "";
|
|
59
|
+
const search = rawSearch.startsWith("?") ? rawSearch.slice(1) : rawSearch;
|
|
60
|
+
const params = new URLSearchParams(search);
|
|
61
|
+
return {
|
|
62
|
+
page_path: globalThis.location.pathname || parsed?.pathname || "",
|
|
63
|
+
query_string: search,
|
|
64
|
+
url: href,
|
|
65
|
+
utm_campaign: params.get("utm_campaign") || undefined,
|
|
66
|
+
utm_medium: params.get("utm_medium") || undefined,
|
|
67
|
+
utm_source: params.get("utm_source") || undefined,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function safeUrl(value) {
|
|
71
|
+
try {
|
|
72
|
+
return new URL(value);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
37
77
|
}
|
|
38
78
|
/**
|
|
39
79
|
* Map a web-vitals Metric to our VitalEvent shape.
|
|
40
80
|
*/
|
|
41
|
-
function mapMetric(metric, config) {
|
|
81
|
+
function mapMetric(metric, config, getCurrentSessionId) {
|
|
82
|
+
const url = getUrlDimensions();
|
|
42
83
|
return {
|
|
43
84
|
type: "vital",
|
|
44
85
|
name: metric.name,
|
|
@@ -46,16 +87,59 @@ function mapMetric(metric, config) {
|
|
|
46
87
|
rating: metric.rating,
|
|
47
88
|
delta: metric.delta,
|
|
48
89
|
navigationType: metric.navigationType,
|
|
49
|
-
url:
|
|
90
|
+
url: url.url,
|
|
91
|
+
session_id: getCurrentSessionId(),
|
|
92
|
+
timestamp: Date.now(),
|
|
93
|
+
environment: config.environment,
|
|
94
|
+
release: config.release,
|
|
95
|
+
connection: getConnectionType(),
|
|
96
|
+
device_type: inferDeviceType(),
|
|
97
|
+
language: globalThis.navigator?.language || undefined,
|
|
98
|
+
page_path: url.page_path,
|
|
99
|
+
query_string: url.query_string,
|
|
100
|
+
referrer: globalThis.document?.referrer || undefined,
|
|
101
|
+
timezone: getBrowserTimezone(),
|
|
102
|
+
utm_campaign: url.utm_campaign,
|
|
103
|
+
utm_medium: url.utm_medium,
|
|
104
|
+
utm_source: url.utm_source,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Map an externally-sourced web vital (Next.js custom metric or otherwise)
|
|
109
|
+
* to our VitalEvent wire shape so it can be enqueued through the transport.
|
|
110
|
+
*/
|
|
111
|
+
export function mapExternalVital(metric, config, route) {
|
|
112
|
+
const url = getUrlDimensions();
|
|
113
|
+
return {
|
|
114
|
+
type: "vital",
|
|
115
|
+
name: metric.name,
|
|
116
|
+
value: metric.value,
|
|
117
|
+
rating: normalizeRating(metric.rating),
|
|
118
|
+
delta: metric.delta ?? metric.value,
|
|
119
|
+
navigationType: metric.navigationType ?? "navigate",
|
|
120
|
+
url: url.url,
|
|
121
|
+
route,
|
|
50
122
|
session_id: getSessionId(),
|
|
51
123
|
timestamp: Date.now(),
|
|
52
124
|
environment: config.environment,
|
|
53
125
|
release: config.release,
|
|
54
126
|
connection: getConnectionType(),
|
|
55
127
|
device_type: inferDeviceType(),
|
|
56
|
-
|
|
128
|
+
language: globalThis.navigator?.language || undefined,
|
|
129
|
+
page_path: url.page_path,
|
|
130
|
+
query_string: url.query_string,
|
|
131
|
+
referrer: globalThis.document?.referrer || undefined,
|
|
132
|
+
timezone: getBrowserTimezone(),
|
|
133
|
+
utm_campaign: url.utm_campaign,
|
|
134
|
+
utm_medium: url.utm_medium,
|
|
135
|
+
utm_source: url.utm_source,
|
|
57
136
|
};
|
|
58
137
|
}
|
|
138
|
+
function normalizeRating(rating) {
|
|
139
|
+
if (rating === "poor" || rating === "needs-improvement")
|
|
140
|
+
return rating;
|
|
141
|
+
return "good";
|
|
142
|
+
}
|
|
59
143
|
/** Decide whether to sample this vital based on the configured rate. */
|
|
60
144
|
function shouldSample(sampleRate) {
|
|
61
145
|
if (sampleRate >= 1)
|
|
@@ -64,19 +148,33 @@ function shouldSample(sampleRate) {
|
|
|
64
148
|
return false;
|
|
65
149
|
return Math.random() < sampleRate;
|
|
66
150
|
}
|
|
151
|
+
export const defaultVitalsRuntime = {
|
|
152
|
+
getSessionId,
|
|
153
|
+
observeCLS: (callback, options) => onCLS(callback, options),
|
|
154
|
+
observeFCP: (callback) => onFCP(callback),
|
|
155
|
+
observeINP: (callback, options) => onINP(callback, options),
|
|
156
|
+
observeLCP: (callback) => onLCP(callback),
|
|
157
|
+
observeTTFB: (callback) => onTTFB(callback),
|
|
158
|
+
};
|
|
67
159
|
/**
|
|
68
160
|
* Start collecting all 5 Core Web Vitals and feed them to the transport.
|
|
69
161
|
* Call once during SDK initialization.
|
|
70
162
|
*/
|
|
71
|
-
export function startVitalsCollection(transport, config) {
|
|
163
|
+
export function startVitalsCollection(transport, config, runtime = defaultVitalsRuntime) {
|
|
164
|
+
let suppressed = false;
|
|
165
|
+
const suppress = () => {
|
|
166
|
+
suppressed = true;
|
|
167
|
+
};
|
|
72
168
|
if (!shouldSample(config.sampleRate)) {
|
|
73
169
|
if (config.debug) {
|
|
74
170
|
console.log("[squasher] Vitals collection skipped (sampled out)");
|
|
75
171
|
}
|
|
76
|
-
return;
|
|
172
|
+
return { suppress };
|
|
77
173
|
}
|
|
78
174
|
const handle = (metric) => {
|
|
79
|
-
|
|
175
|
+
if (suppressed)
|
|
176
|
+
return;
|
|
177
|
+
const event = mapMetric(metric, config, runtime.getSessionId);
|
|
80
178
|
if (config.debug) {
|
|
81
179
|
console.log(`[squasher] ${metric.name}: ${metric.value.toFixed(2)} (${metric.rating})`);
|
|
82
180
|
}
|
|
@@ -84,9 +182,10 @@ export function startVitalsCollection(transport, config) {
|
|
|
84
182
|
};
|
|
85
183
|
// Register all 5 metric observers.
|
|
86
184
|
// reportAllChanges: true for CLS/INP so we get cumulative values.
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
185
|
+
runtime.observeLCP(handle);
|
|
186
|
+
runtime.observeCLS(handle, { reportAllChanges: true });
|
|
187
|
+
runtime.observeINP(handle, { reportAllChanges: true });
|
|
188
|
+
runtime.observeFCP(handle);
|
|
189
|
+
runtime.observeTTFB(handle);
|
|
190
|
+
return { suppress };
|
|
92
191
|
}
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@squasher-ai/browser",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Official @squasher-ai/browser package for Squasher.",
|
|
5
|
-
"homepage": "https://
|
|
5
|
+
"homepage": "https://squasher.ai",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
@@ -18,17 +18,25 @@
|
|
|
18
18
|
"./react": {
|
|
19
19
|
"import": "./dist/react.js",
|
|
20
20
|
"types": "./dist/react.d.ts"
|
|
21
|
+
},
|
|
22
|
+
"./next": {
|
|
23
|
+
"import": "./dist/next.js",
|
|
24
|
+
"types": "./dist/next.d.ts"
|
|
21
25
|
}
|
|
22
26
|
},
|
|
23
27
|
"sideEffects": false,
|
|
24
28
|
"dependencies": {
|
|
25
|
-
"rrweb": "^2.0.0-alpha.
|
|
29
|
+
"rrweb": "^2.0.0-alpha.20",
|
|
26
30
|
"web-vitals": "^4.2.4"
|
|
27
31
|
},
|
|
28
32
|
"peerDependencies": {
|
|
33
|
+
"next": ">=14",
|
|
29
34
|
"react": ">=18"
|
|
30
35
|
},
|
|
31
36
|
"peerDependenciesMeta": {
|
|
37
|
+
"next": {
|
|
38
|
+
"optional": true
|
|
39
|
+
},
|
|
32
40
|
"react": {
|
|
33
41
|
"optional": true
|
|
34
42
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"client.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/client.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,103 +0,0 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { BrowserClient } from "../client";
|
|
3
|
-
import { resetSessionId } from "../session";
|
|
4
|
-
describe("BrowserClient", () => {
|
|
5
|
-
beforeEach(() => {
|
|
6
|
-
resetSessionId();
|
|
7
|
-
const storage = {};
|
|
8
|
-
vi.restoreAllMocks();
|
|
9
|
-
vi.stubGlobal("crypto", {
|
|
10
|
-
randomUUID: vi.fn().mockReturnValue("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"),
|
|
11
|
-
});
|
|
12
|
-
vi.stubGlobal("sessionStorage", {
|
|
13
|
-
getItem: (key) => storage[key] ?? null,
|
|
14
|
-
setItem: (key, value) => {
|
|
15
|
-
storage[key] = value;
|
|
16
|
-
},
|
|
17
|
-
removeItem: (key) => {
|
|
18
|
-
delete storage[key];
|
|
19
|
-
},
|
|
20
|
-
});
|
|
21
|
-
vi.stubGlobal("navigator", {
|
|
22
|
-
sendBeacon: vi.fn().mockReturnValue(true),
|
|
23
|
-
});
|
|
24
|
-
vi.stubGlobal("document", {
|
|
25
|
-
addEventListener: vi.fn(),
|
|
26
|
-
referrer: "https://referrer.example",
|
|
27
|
-
title: "Settings",
|
|
28
|
-
});
|
|
29
|
-
vi.stubGlobal("window", {
|
|
30
|
-
addEventListener: vi.fn(),
|
|
31
|
-
});
|
|
32
|
-
vi.stubGlobal("location", {
|
|
33
|
-
href: "https://example.com/settings",
|
|
34
|
-
pathname: "/settings",
|
|
35
|
-
search: "?tab=profile",
|
|
36
|
-
});
|
|
37
|
-
});
|
|
38
|
-
it("attaches the current session ID to captured error events", async () => {
|
|
39
|
-
const client = new BrowserClient({
|
|
40
|
-
apiKey: "sq_pk_test",
|
|
41
|
-
projectId: "project-123",
|
|
42
|
-
enableVitals: false,
|
|
43
|
-
enableErrorCapture: false,
|
|
44
|
-
enableAutoBreadcrumbs: false,
|
|
45
|
-
});
|
|
46
|
-
client.captureError(new Error("boom"));
|
|
47
|
-
const sendBeaconMock = vi.mocked(navigator.sendBeacon);
|
|
48
|
-
expect(sendBeaconMock).toHaveBeenCalledOnce();
|
|
49
|
-
const [url, payloadBlob] = sendBeaconMock.mock.calls[0] ?? [];
|
|
50
|
-
expect(url).toBe("https://ingest.squasher.ai/v1/ingest/project-123?key=sq_pk_test");
|
|
51
|
-
expect(payloadBlob).toBeInstanceOf(Blob);
|
|
52
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
53
|
-
throw new Error("expected ingest payload to be a Blob");
|
|
54
|
-
}
|
|
55
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
56
|
-
expect(payload.session_id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
|
57
|
-
expect(payload.request?.url).toBe("https://example.com/settings");
|
|
58
|
-
client.close();
|
|
59
|
-
});
|
|
60
|
-
it("track sends analytics metadata with visitor context", async () => {
|
|
61
|
-
const client = new BrowserClient({
|
|
62
|
-
apiKey: "sq_pk_test",
|
|
63
|
-
projectId: "project-123",
|
|
64
|
-
enableVitals: false,
|
|
65
|
-
enableErrorCapture: false,
|
|
66
|
-
enableAutoBreadcrumbs: false,
|
|
67
|
-
});
|
|
68
|
-
client.track("checkout_started", { step: 1 });
|
|
69
|
-
const [_, payloadBlob] = vi.mocked(navigator.sendBeacon).mock.calls[0] ?? [];
|
|
70
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
71
|
-
throw new Error("expected ingest payload to be a Blob");
|
|
72
|
-
}
|
|
73
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
74
|
-
expect(payload.kind).toBe("analytics");
|
|
75
|
-
expect(payload.event_name).toBe("checkout_started");
|
|
76
|
-
expect(payload.analytics?.event).toBe("checkout_started");
|
|
77
|
-
expect(payload.analytics?.properties?.step).toBe(1);
|
|
78
|
-
expect(payload.visitor?.anonymous_id).toBe("aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee");
|
|
79
|
-
client.close();
|
|
80
|
-
});
|
|
81
|
-
it("page sends normalized page metadata", async () => {
|
|
82
|
-
const client = new BrowserClient({
|
|
83
|
-
apiKey: "sq_pk_test",
|
|
84
|
-
projectId: "project-123",
|
|
85
|
-
enableVitals: false,
|
|
86
|
-
enableErrorCapture: false,
|
|
87
|
-
enableAutoBreadcrumbs: false,
|
|
88
|
-
});
|
|
89
|
-
client.page("Settings");
|
|
90
|
-
const [_, payloadBlob] = vi.mocked(navigator.sendBeacon).mock.calls[0] ?? [];
|
|
91
|
-
if (!(payloadBlob instanceof Blob)) {
|
|
92
|
-
throw new Error("expected ingest payload to be a Blob");
|
|
93
|
-
}
|
|
94
|
-
const payload = JSON.parse(await payloadBlob.text());
|
|
95
|
-
expect(payload.kind).toBe("page");
|
|
96
|
-
expect(payload.page?.name).toBe("Settings");
|
|
97
|
-
expect(payload.page?.path).toBe("/settings");
|
|
98
|
-
expect(payload.page?.title).toBe("Settings");
|
|
99
|
-
expect(payload.page?.referrer).toBe("https://referrer.example");
|
|
100
|
-
expect(payload.page?.search).toBe("?tab=profile");
|
|
101
|
-
client.close();
|
|
102
|
-
});
|
|
103
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"errors.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/errors.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { parseStack, buildErrorEvent, buildMessageEvent } from "../errors";
|
|
3
|
-
describe("parseStack", () => {
|
|
4
|
-
it("parses V8 stack traces", () => {
|
|
5
|
-
const stack = `Error: Something went wrong
|
|
6
|
-
at functionName (https://example.com/app.js:10:20)
|
|
7
|
-
at anotherFunction (https://example.com/utils.js:5:3)
|
|
8
|
-
at Object.<anonymous> (node_modules/some-lib/index.js:100:15)`;
|
|
9
|
-
const frames = parseStack(stack);
|
|
10
|
-
expect(frames).toBeDefined();
|
|
11
|
-
expect(frames).toHaveLength(3);
|
|
12
|
-
expect(frames[0]).toEqual({
|
|
13
|
-
function: "functionName",
|
|
14
|
-
filename: "https://example.com/app.js",
|
|
15
|
-
lineno: 10,
|
|
16
|
-
colno: 20,
|
|
17
|
-
in_app: true,
|
|
18
|
-
});
|
|
19
|
-
expect(frames[1]).toEqual({
|
|
20
|
-
function: "anotherFunction",
|
|
21
|
-
filename: "https://example.com/utils.js",
|
|
22
|
-
lineno: 5,
|
|
23
|
-
colno: 3,
|
|
24
|
-
in_app: true,
|
|
25
|
-
});
|
|
26
|
-
// node_modules should be marked as not in_app
|
|
27
|
-
expect(frames[2].in_app).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
it("handles anonymous functions", () => {
|
|
30
|
-
const stack = `Error: test
|
|
31
|
-
at https://example.com/app.js:1:1`;
|
|
32
|
-
const frames = parseStack(stack);
|
|
33
|
-
expect(frames).toBeDefined();
|
|
34
|
-
expect(frames).toHaveLength(1);
|
|
35
|
-
expect(frames[0].function).toBe("<anonymous>");
|
|
36
|
-
});
|
|
37
|
-
it("returns undefined for empty stack", () => {
|
|
38
|
-
expect(parseStack(undefined)).toBeUndefined();
|
|
39
|
-
expect(parseStack("Error: test")).toBeUndefined();
|
|
40
|
-
});
|
|
41
|
-
it("marks chrome extensions as not in_app", () => {
|
|
42
|
-
const stack = `Error: ext
|
|
43
|
-
at handler (chrome-extension://abcdef/content.js:5:10)`;
|
|
44
|
-
const frames = parseStack(stack);
|
|
45
|
-
expect(frames).toBeDefined();
|
|
46
|
-
expect(frames[0].in_app).toBe(false);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
49
|
-
describe("buildErrorEvent", () => {
|
|
50
|
-
it("builds a complete error event", () => {
|
|
51
|
-
const error = new Error("Test error");
|
|
52
|
-
error.name = "TypeError";
|
|
53
|
-
const event = buildErrorEvent(error, { extra: "data" });
|
|
54
|
-
expect(event.message).toBe("Test error");
|
|
55
|
-
expect(event.type).toBe("TypeError");
|
|
56
|
-
expect(event.level).toBe("error");
|
|
57
|
-
expect(event.sdk?.name).toBe("@squasher-ai/browser");
|
|
58
|
-
expect(event.sdk?.version).toBe("0.1.0");
|
|
59
|
-
expect(event.timestamp).toBeDefined();
|
|
60
|
-
expect(event.extra).toEqual({ extra: "data" });
|
|
61
|
-
expect(event.stack).toBeDefined();
|
|
62
|
-
});
|
|
63
|
-
it("builds event without extra", () => {
|
|
64
|
-
const error = new Error("Simple error");
|
|
65
|
-
const event = buildErrorEvent(error);
|
|
66
|
-
expect(event.extra).toBeUndefined();
|
|
67
|
-
});
|
|
68
|
-
});
|
|
69
|
-
describe("buildMessageEvent", () => {
|
|
70
|
-
it("builds a message event with default level", () => {
|
|
71
|
-
const event = buildMessageEvent("Hello");
|
|
72
|
-
expect(event.message).toBe("Hello");
|
|
73
|
-
expect(event.level).toBe("info");
|
|
74
|
-
expect(event.sdk?.name).toBe("@squasher-ai/browser");
|
|
75
|
-
});
|
|
76
|
-
it("builds a message event with custom level", () => {
|
|
77
|
-
const event = buildMessageEvent("Warning!", "warning");
|
|
78
|
-
expect(event.level).toBe("warning");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"replay-privacy.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay-privacy.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,124 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { applyReplayEventPrivacy, buildReplayRecordOptions, MASK_ALL_TEXT_SELECTOR, } from "../replay-privacy";
|
|
3
|
-
describe("replay privacy helpers", () => {
|
|
4
|
-
it("builds rrweb options for text masking and blocked selectors", () => {
|
|
5
|
-
const emit = vi.fn();
|
|
6
|
-
expect(buildReplayRecordOptions({
|
|
7
|
-
emit,
|
|
8
|
-
privacy: {
|
|
9
|
-
blockSelector: ".pii-container",
|
|
10
|
-
maskAllText: true,
|
|
11
|
-
},
|
|
12
|
-
})).toMatchObject({
|
|
13
|
-
blockSelector: ".pii-container",
|
|
14
|
-
emit,
|
|
15
|
-
maskAllInputs: true,
|
|
16
|
-
maskAllText: true,
|
|
17
|
-
maskTextSelector: MASK_ALL_TEXT_SELECTOR,
|
|
18
|
-
});
|
|
19
|
-
});
|
|
20
|
-
it("redacts replay text fields without touching non-text attributes", () => {
|
|
21
|
-
const event = {
|
|
22
|
-
data: {
|
|
23
|
-
node: {
|
|
24
|
-
attributes: {
|
|
25
|
-
"aria-label": "Customer email",
|
|
26
|
-
placeholder: "you@example.com",
|
|
27
|
-
style: "color:#0f172a",
|
|
28
|
-
title: "Checkout form",
|
|
29
|
-
value: "Customer email",
|
|
30
|
-
},
|
|
31
|
-
childNodes: [
|
|
32
|
-
{
|
|
33
|
-
textContent: "Jane Example",
|
|
34
|
-
type: 3,
|
|
35
|
-
},
|
|
36
|
-
],
|
|
37
|
-
tagName: "input",
|
|
38
|
-
type: 2,
|
|
39
|
-
},
|
|
40
|
-
source: 5,
|
|
41
|
-
text: "customer@example.com",
|
|
42
|
-
},
|
|
43
|
-
timestamp: 1,
|
|
44
|
-
type: 3,
|
|
45
|
-
};
|
|
46
|
-
const redactedEvent = applyReplayEventPrivacy(event, { maskAllText: true });
|
|
47
|
-
const serializedEvent = JSON.stringify(redactedEvent);
|
|
48
|
-
expect(redactedEvent).not.toBe(event);
|
|
49
|
-
expect(serializedEvent).not.toContain("Jane Example");
|
|
50
|
-
expect(serializedEvent).not.toContain("you@example.com");
|
|
51
|
-
expect(serializedEvent).not.toContain("customer@example.com");
|
|
52
|
-
expect(serializedEvent).not.toContain("Checkout form");
|
|
53
|
-
expect(serializedEvent).toContain("color:#0f172a");
|
|
54
|
-
expect(serializedEvent).toContain("************");
|
|
55
|
-
expect(event.data.text).toBe("customer@example.com");
|
|
56
|
-
});
|
|
57
|
-
it("redacts inline style and script text alongside visible text nodes", () => {
|
|
58
|
-
const event = {
|
|
59
|
-
data: {
|
|
60
|
-
node: {
|
|
61
|
-
childNodes: [
|
|
62
|
-
{
|
|
63
|
-
childNodes: [
|
|
64
|
-
{
|
|
65
|
-
childNodes: [
|
|
66
|
-
{
|
|
67
|
-
tagName: "style",
|
|
68
|
-
childNodes: [
|
|
69
|
-
{
|
|
70
|
-
textContent: "body{background:#0f172a;color:#e2e8f0;}",
|
|
71
|
-
type: 3,
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
type: 2,
|
|
75
|
-
},
|
|
76
|
-
{
|
|
77
|
-
tagName: "script",
|
|
78
|
-
childNodes: [
|
|
79
|
-
{
|
|
80
|
-
textContent: 'window.__BOOTSTRAP__={email:"jane@example.com"};',
|
|
81
|
-
type: 3,
|
|
82
|
-
},
|
|
83
|
-
],
|
|
84
|
-
type: 2,
|
|
85
|
-
},
|
|
86
|
-
{
|
|
87
|
-
childNodes: [
|
|
88
|
-
{
|
|
89
|
-
textContent: "Private checkout summary",
|
|
90
|
-
type: 3,
|
|
91
|
-
},
|
|
92
|
-
],
|
|
93
|
-
tagName: "h1",
|
|
94
|
-
type: 2,
|
|
95
|
-
},
|
|
96
|
-
],
|
|
97
|
-
tagName: "body",
|
|
98
|
-
type: 2,
|
|
99
|
-
},
|
|
100
|
-
],
|
|
101
|
-
tagName: "html",
|
|
102
|
-
type: 2,
|
|
103
|
-
},
|
|
104
|
-
],
|
|
105
|
-
type: 0,
|
|
106
|
-
},
|
|
107
|
-
},
|
|
108
|
-
timestamp: 1,
|
|
109
|
-
type: 2,
|
|
110
|
-
};
|
|
111
|
-
const redactedEvent = applyReplayEventPrivacy(event, { maskAllText: true });
|
|
112
|
-
const serializedEvent = JSON.stringify(redactedEvent);
|
|
113
|
-
expect(serializedEvent).not.toContain("body{background:#0f172a;color:#e2e8f0;}");
|
|
114
|
-
expect(serializedEvent).not.toContain('window.__BOOTSTRAP__={email:"jane@example.com"};');
|
|
115
|
-
expect(serializedEvent).not.toContain("Private checkout summary");
|
|
116
|
-
expect(serializedEvent).toContain("******* ******** *******");
|
|
117
|
-
expect(serializedEvent).toMatch(/\*{10,}/);
|
|
118
|
-
});
|
|
119
|
-
it("returns the original replay event when text masking is disabled", () => {
|
|
120
|
-
const event = { data: { text: "customer@example.com" }, timestamp: 1, type: 3 };
|
|
121
|
-
expect(applyReplayEventPrivacy(event, { maskAllText: false })).toBe(event);
|
|
122
|
-
expect(applyReplayEventPrivacy(event)).toBe(event);
|
|
123
|
-
});
|
|
124
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"replay-startup.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay-startup.test.ts"],"names":[],"mappings":""}
|
|
@@ -1,99 +0,0 @@
|
|
|
1
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { ReplayRecorder } from "../replay";
|
|
3
|
-
import { resetSessionId } from "../session";
|
|
4
|
-
const rrwebRecordMock = vi.hoisted(() => vi.fn());
|
|
5
|
-
const rrwebStopMock = vi.hoisted(() => vi.fn());
|
|
6
|
-
vi.mock("rrweb", () => ({
|
|
7
|
-
record: rrwebRecordMock,
|
|
8
|
-
}));
|
|
9
|
-
function createReplayEvent(type, timestamp) {
|
|
10
|
-
return {
|
|
11
|
-
type,
|
|
12
|
-
timestamp,
|
|
13
|
-
data: {
|
|
14
|
-
source: type,
|
|
15
|
-
},
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
describe("ReplayRecorder startup behavior", () => {
|
|
19
|
-
let emitReplayEvent;
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
resetSessionId();
|
|
22
|
-
emitReplayEvent = undefined;
|
|
23
|
-
vi.useFakeTimers();
|
|
24
|
-
vi.restoreAllMocks();
|
|
25
|
-
const storage = {};
|
|
26
|
-
vi.stubGlobal("crypto", {
|
|
27
|
-
randomUUID: vi.fn().mockReturnValue("11111111-2222-4333-8444-555555555555"),
|
|
28
|
-
});
|
|
29
|
-
vi.stubGlobal("sessionStorage", {
|
|
30
|
-
getItem: (key) => storage[key] ?? null,
|
|
31
|
-
setItem: (key, value) => {
|
|
32
|
-
storage[key] = value;
|
|
33
|
-
},
|
|
34
|
-
removeItem: (key) => {
|
|
35
|
-
delete storage[key];
|
|
36
|
-
},
|
|
37
|
-
});
|
|
38
|
-
vi.stubGlobal("location", {
|
|
39
|
-
href: "https://example.com/dashboard",
|
|
40
|
-
});
|
|
41
|
-
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true }));
|
|
42
|
-
vi.stubGlobal("navigator", {
|
|
43
|
-
sendBeacon: vi.fn().mockReturnValue(true),
|
|
44
|
-
});
|
|
45
|
-
vi.stubGlobal("document", {});
|
|
46
|
-
vi.stubGlobal("window", {
|
|
47
|
-
addEventListener: vi.fn(),
|
|
48
|
-
removeEventListener: vi.fn(),
|
|
49
|
-
});
|
|
50
|
-
rrwebStopMock.mockReset();
|
|
51
|
-
rrwebRecordMock.mockReset();
|
|
52
|
-
rrwebRecordMock.mockImplementation((options) => {
|
|
53
|
-
emitReplayEvent = options?.emit;
|
|
54
|
-
return rrwebStopMock;
|
|
55
|
-
});
|
|
56
|
-
});
|
|
57
|
-
afterEach(() => {
|
|
58
|
-
vi.useRealTimers();
|
|
59
|
-
vi.unstubAllGlobals();
|
|
60
|
-
});
|
|
61
|
-
it("preserves rrweb bootstrap events emitted before record() returns", async () => {
|
|
62
|
-
rrwebRecordMock.mockImplementation((options) => {
|
|
63
|
-
options?.emit?.(createReplayEvent(4, 1));
|
|
64
|
-
options?.emit?.(createReplayEvent(2, 2));
|
|
65
|
-
emitReplayEvent = options?.emit;
|
|
66
|
-
return rrwebStopMock;
|
|
67
|
-
});
|
|
68
|
-
const recorder = new ReplayRecorder({
|
|
69
|
-
endpoint: "https://ingest.squasher.ai",
|
|
70
|
-
projectId: "project-123",
|
|
71
|
-
apiKey: "sq_pk_test",
|
|
72
|
-
debug: false,
|
|
73
|
-
sampleRate: 1,
|
|
74
|
-
});
|
|
75
|
-
await recorder.ready;
|
|
76
|
-
emitReplayEvent?.(createReplayEvent(3, 3));
|
|
77
|
-
vi.advanceTimersByTime(5_000);
|
|
78
|
-
const requestBody = vi.mocked(fetch).mock.calls[0]?.[1]?.body;
|
|
79
|
-
expect(typeof requestBody).toBe("string");
|
|
80
|
-
if (typeof requestBody !== "string") {
|
|
81
|
-
throw new Error("expected replay request body to be a string");
|
|
82
|
-
}
|
|
83
|
-
const payload = JSON.parse(requestBody);
|
|
84
|
-
expect(payload.events.map((event) => event.type)).toEqual([4, 2, 3]);
|
|
85
|
-
recorder.dispose();
|
|
86
|
-
});
|
|
87
|
-
it("skips recording when the replay sample rate drops the session", () => {
|
|
88
|
-
vi.spyOn(Math, "random").mockReturnValue(0.9);
|
|
89
|
-
const recorder = new ReplayRecorder({
|
|
90
|
-
endpoint: "https://ingest.squasher.ai",
|
|
91
|
-
projectId: "project-123",
|
|
92
|
-
apiKey: "sq_pk_test",
|
|
93
|
-
debug: false,
|
|
94
|
-
sampleRate: 0.5,
|
|
95
|
-
});
|
|
96
|
-
expect(rrwebRecordMock).not.toHaveBeenCalled();
|
|
97
|
-
recorder.dispose();
|
|
98
|
-
});
|
|
99
|
-
});
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"replay.test.d.ts","sourceRoot":"","sources":["../../src/__tests__/replay.test.ts"],"names":[],"mappings":""}
|