@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/next.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js Web Vitals bridge for @squasher-ai/browser.
|
|
3
|
+
*
|
|
4
|
+
* Drop `<SquasherNextWebVitals />` into your root layout to capture both
|
|
5
|
+
* Core Web Vitals (LCP, CLS, INP, FCP, TTFB) and Next.js framework metrics
|
|
6
|
+
* (`Next.js-hydration`, `Next.js-route-change-to-render`, `Next.js-render`).
|
|
7
|
+
*
|
|
8
|
+
* The component hard-imports `next/web-vitals` and `next/navigation` so it
|
|
9
|
+
* is **only** valid in a Next.js app. Non-Next consumers should rely on the
|
|
10
|
+
* auto-collector that runs from `init()` and not import this module.
|
|
11
|
+
*
|
|
12
|
+
* Internally it bridges Next's `useReportWebVitals` hook into the existing
|
|
13
|
+
* browser client transport, suppressing the SDK's native `web-vitals`
|
|
14
|
+
* collector on mount so Core metrics aren't reported twice.
|
|
15
|
+
*
|
|
16
|
+
* Usage:
|
|
17
|
+
* import { SquasherNextWebVitals } from '@squasher-ai/browser/next';
|
|
18
|
+
*
|
|
19
|
+
* export default function RootLayout({ children }) {
|
|
20
|
+
* return (
|
|
21
|
+
* <html>
|
|
22
|
+
* <body>
|
|
23
|
+
* <SquasherNextWebVitals />
|
|
24
|
+
* {children}
|
|
25
|
+
* </body>
|
|
26
|
+
* </html>
|
|
27
|
+
* );
|
|
28
|
+
* }
|
|
29
|
+
*/
|
|
30
|
+
"use client";
|
|
31
|
+
import { usePathname } from "next/navigation";
|
|
32
|
+
import { useReportWebVitals } from "next/web-vitals";
|
|
33
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
34
|
+
import { getClient } from "./runtime/client-singleton.js";
|
|
35
|
+
const SUPPORTED_WEB_VITALS = new Set(["CLS", "FCP", "INP", "LCP", "TTFB"]);
|
|
36
|
+
function isSupportedWebVital(name) {
|
|
37
|
+
return SUPPORTED_WEB_VITALS.has(name);
|
|
38
|
+
}
|
|
39
|
+
export function SquasherNextWebVitals({ path } = {}) {
|
|
40
|
+
const pathname = usePathname();
|
|
41
|
+
const currentPath = path ?? pathname ?? "/";
|
|
42
|
+
const stabilizedPath = useRef(currentPath);
|
|
43
|
+
useEffect(() => {
|
|
44
|
+
stabilizedPath.current = currentPath;
|
|
45
|
+
}, [currentPath]);
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
try {
|
|
48
|
+
getClient().suppressNativeVitalsCollection();
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// Squasher SDK not initialized — nothing to suppress. Bridge still
|
|
52
|
+
// runs so the host can call init() asynchronously.
|
|
53
|
+
}
|
|
54
|
+
}, []);
|
|
55
|
+
const report = useCallback((metric) => {
|
|
56
|
+
if (!isSupportedWebVital(metric.name))
|
|
57
|
+
return;
|
|
58
|
+
try {
|
|
59
|
+
getClient().recordWebVital(metric, stabilizedPath.current);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// SDK not yet initialized; drop this metric rather than throw.
|
|
63
|
+
}
|
|
64
|
+
}, []);
|
|
65
|
+
useReportWebVitals(report);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
package/dist/react.d.ts
CHANGED
package/dist/react.js
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
* </SquasherErrorBoundary>
|
|
13
13
|
*/
|
|
14
14
|
import { Component } from "react";
|
|
15
|
-
import {
|
|
15
|
+
import { attempt } from "./_vendor/result-runtime/attempt.js";
|
|
16
|
+
import { getClient } from "./runtime/client-singleton.js";
|
|
16
17
|
export class SquasherErrorBoundary extends Component {
|
|
17
18
|
constructor(props) {
|
|
18
19
|
super(props);
|
|
@@ -22,15 +23,16 @@ export class SquasherErrorBoundary extends Component {
|
|
|
22
23
|
return { hasError: true };
|
|
23
24
|
}
|
|
24
25
|
componentDidCatch(error, errorInfo) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
26
|
+
attempt({
|
|
27
|
+
try: () => {
|
|
28
|
+
const client = getClient();
|
|
29
|
+
const extra = {};
|
|
30
|
+
if (errorInfo.componentStack)
|
|
31
|
+
extra.componentStack = errorInfo.componentStack;
|
|
32
|
+
client.captureError(error, extra);
|
|
33
|
+
},
|
|
34
|
+
catch: () => undefined,
|
|
35
|
+
});
|
|
34
36
|
this.props.onError?.(error, errorInfo);
|
|
35
37
|
}
|
|
36
38
|
render() {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface ReplayNetworkEventPayload {
|
|
2
|
+
durationMs: number;
|
|
3
|
+
initiatorType: string;
|
|
4
|
+
method?: string;
|
|
5
|
+
pageUrl: string;
|
|
6
|
+
protocol?: string | null;
|
|
7
|
+
requestUrl: string;
|
|
8
|
+
source: "fetch" | "resource";
|
|
9
|
+
startOffsetMs: number;
|
|
10
|
+
statusCode?: number | null;
|
|
11
|
+
statusText?: string | null;
|
|
12
|
+
transferSize?: number | null;
|
|
13
|
+
}
|
|
14
|
+
export interface ReplayNetworkCaptureOptions {
|
|
15
|
+
debug: boolean;
|
|
16
|
+
emitCustomEvent?: (tag: string, payload: ReplayNetworkEventPayload) => void;
|
|
17
|
+
ignoredUrlPrefixes?: string[];
|
|
18
|
+
replayStartedAtMs: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function installReplayNetworkCapture({ debug, emitCustomEvent, ignoredUrlPrefixes, replayStartedAtMs, }: ReplayNetworkCaptureOptions): () => void;
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { attempt, attemptAsync } from "./_vendor/result-runtime/attempt.js";
|
|
2
|
+
import { readRuntimeNumber, readRuntimeProperty, readRuntimeString } from "./_vendor/sdk-runtime/runtime/public-sdk-runtime.js";
|
|
3
|
+
const NETWORK_EVENT_TAG = "squasher:network";
|
|
4
|
+
const MAX_NETWORK_EVENTS = 400;
|
|
5
|
+
function clampOffset(offsetMs) {
|
|
6
|
+
return Math.max(0, Math.round(offsetMs));
|
|
7
|
+
}
|
|
8
|
+
function roundDuration(durationMs) {
|
|
9
|
+
return Math.max(0, Math.round(durationMs));
|
|
10
|
+
}
|
|
11
|
+
function readFiniteNumber(value) {
|
|
12
|
+
return value !== null && value !== undefined && Number.isFinite(value) ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function readFiniteString(value) {
|
|
15
|
+
return value && value.length > 0 ? value : null;
|
|
16
|
+
}
|
|
17
|
+
function getCurrentPageUrl() {
|
|
18
|
+
return globalThis.location?.href ?? "";
|
|
19
|
+
}
|
|
20
|
+
function shouldIgnoreRequestUrl(requestUrl, ignoredUrlPrefixes) {
|
|
21
|
+
return ignoredUrlPrefixes.some((prefix) => requestUrl.startsWith(prefix));
|
|
22
|
+
}
|
|
23
|
+
function toResourceKey(payload) {
|
|
24
|
+
return [
|
|
25
|
+
payload.source,
|
|
26
|
+
payload.requestUrl,
|
|
27
|
+
payload.initiatorType,
|
|
28
|
+
payload.startOffsetMs,
|
|
29
|
+
payload.durationMs,
|
|
30
|
+
].join("|");
|
|
31
|
+
}
|
|
32
|
+
function buildResourcePayload(entry, replayStartedAtMs) {
|
|
33
|
+
if (entry.entryType !== "resource" && entry.entryType !== "navigation") {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const initiatorType = readRuntimeString(readRuntimeProperty(entry, "initiatorType"));
|
|
37
|
+
const nextHopProtocol = readRuntimeString(readRuntimeProperty(entry, "nextHopProtocol"));
|
|
38
|
+
const renderBlockingStatus = readRuntimeString(readRuntimeProperty(entry, "renderBlockingStatus"));
|
|
39
|
+
const responseStatus = readRuntimeNumber(readRuntimeProperty(entry, "responseStatus"));
|
|
40
|
+
const transferSize = readRuntimeNumber(readRuntimeProperty(entry, "transferSize"));
|
|
41
|
+
const absoluteStartMs = performance.timeOrigin + entry.startTime;
|
|
42
|
+
return {
|
|
43
|
+
durationMs: roundDuration(entry.duration),
|
|
44
|
+
initiatorType: entry.entryType === "navigation"
|
|
45
|
+
? "navigation"
|
|
46
|
+
: readFiniteString(initiatorType) || "resource",
|
|
47
|
+
pageUrl: getCurrentPageUrl(),
|
|
48
|
+
protocol: readFiniteString(nextHopProtocol),
|
|
49
|
+
requestUrl: entry.name,
|
|
50
|
+
source: "resource",
|
|
51
|
+
startOffsetMs: clampOffset(absoluteStartMs - replayStartedAtMs),
|
|
52
|
+
statusCode: readFiniteNumber(responseStatus),
|
|
53
|
+
statusText: readFiniteString(renderBlockingStatus),
|
|
54
|
+
transferSize: readFiniteNumber(transferSize),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export function installReplayNetworkCapture({ debug, emitCustomEvent, ignoredUrlPrefixes = [], replayStartedAtMs, }) {
|
|
58
|
+
if (!emitCustomEvent || !globalThis.window) {
|
|
59
|
+
return () => undefined;
|
|
60
|
+
}
|
|
61
|
+
let emittedCount = 0;
|
|
62
|
+
let customEventAvailable = true;
|
|
63
|
+
const seenResources = new Set();
|
|
64
|
+
const cleanups = [];
|
|
65
|
+
const emit = (payload) => {
|
|
66
|
+
if (!customEventAvailable || emittedCount >= MAX_NETWORK_EVENTS) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const emitted = attempt({
|
|
70
|
+
try: () => {
|
|
71
|
+
emitCustomEvent(NETWORK_EVENT_TAG, payload);
|
|
72
|
+
return true;
|
|
73
|
+
},
|
|
74
|
+
catch: ({ error }) => {
|
|
75
|
+
customEventAvailable = false;
|
|
76
|
+
if (debug) {
|
|
77
|
+
console.warn("[squasher] Replay network event capture disabled", {
|
|
78
|
+
error: error.message,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return false;
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
if (!emitted)
|
|
85
|
+
return;
|
|
86
|
+
emittedCount += 1;
|
|
87
|
+
if (debug) {
|
|
88
|
+
console.log("[squasher] Replay network event recorded", {
|
|
89
|
+
requestUrl: payload.requestUrl,
|
|
90
|
+
source: payload.source,
|
|
91
|
+
statusCode: payload.statusCode ?? null,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
if (globalThis.PerformanceObserver) {
|
|
96
|
+
const supportedEntryTypes = globalThis.PerformanceObserver.supportedEntryTypes ?? [];
|
|
97
|
+
const observer = new globalThis.PerformanceObserver((list) => {
|
|
98
|
+
for (const entry of list.getEntries()) {
|
|
99
|
+
const payload = buildResourcePayload(entry, replayStartedAtMs);
|
|
100
|
+
if (!payload) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (shouldIgnoreRequestUrl(payload.requestUrl, ignoredUrlPrefixes)) {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const absoluteStartMs = performance.timeOrigin + entry.startTime;
|
|
107
|
+
if (payload.initiatorType === "fetch" && absoluteStartMs >= replayStartedAtMs) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const key = toResourceKey(payload);
|
|
111
|
+
if (seenResources.has(key)) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
seenResources.add(key);
|
|
115
|
+
emit(payload);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
attempt({
|
|
119
|
+
try: () => {
|
|
120
|
+
if (supportedEntryTypes.includes("navigation")) {
|
|
121
|
+
observer.observe({ buffered: true, type: "navigation" });
|
|
122
|
+
}
|
|
123
|
+
if (supportedEntryTypes.includes("resource")) {
|
|
124
|
+
observer.observe({ buffered: true, type: "resource" });
|
|
125
|
+
}
|
|
126
|
+
cleanups.push(() => observer.disconnect());
|
|
127
|
+
},
|
|
128
|
+
catch: () => {
|
|
129
|
+
observer.disconnect();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
if (window.fetch) {
|
|
134
|
+
const originalFetch = window.fetch.bind(window);
|
|
135
|
+
window.fetch = async (input, init) => {
|
|
136
|
+
const requestStartedAtMs = Date.now();
|
|
137
|
+
const inputIsRequest = globalThis.Request && input instanceof globalThis.Request;
|
|
138
|
+
const method = init?.method?.toUpperCase() || (inputIsRequest ? input.method.toUpperCase() : "GET");
|
|
139
|
+
const requestUrl = Object.prototype.toString.call(input) === "[object String]"
|
|
140
|
+
? String(input)
|
|
141
|
+
: input instanceof URL
|
|
142
|
+
? input.href
|
|
143
|
+
: inputIsRequest
|
|
144
|
+
? input.url
|
|
145
|
+
: String(input);
|
|
146
|
+
const pageUrl = getCurrentPageUrl();
|
|
147
|
+
if (shouldIgnoreRequestUrl(requestUrl, ignoredUrlPrefixes)) {
|
|
148
|
+
return originalFetch(input, init);
|
|
149
|
+
}
|
|
150
|
+
return attemptAsync({
|
|
151
|
+
try: async () => {
|
|
152
|
+
const response = await originalFetch(input, init);
|
|
153
|
+
emit({
|
|
154
|
+
durationMs: roundDuration(Date.now() - requestStartedAtMs),
|
|
155
|
+
initiatorType: "fetch",
|
|
156
|
+
method,
|
|
157
|
+
pageUrl,
|
|
158
|
+
requestUrl,
|
|
159
|
+
source: "fetch",
|
|
160
|
+
startOffsetMs: clampOffset(requestStartedAtMs - replayStartedAtMs),
|
|
161
|
+
statusCode: response.status,
|
|
162
|
+
statusText: response.statusText || null,
|
|
163
|
+
transferSize: null,
|
|
164
|
+
});
|
|
165
|
+
return response;
|
|
166
|
+
},
|
|
167
|
+
catch: ({ cause, error }) => {
|
|
168
|
+
emit({
|
|
169
|
+
durationMs: roundDuration(Date.now() - requestStartedAtMs),
|
|
170
|
+
initiatorType: "fetch",
|
|
171
|
+
method,
|
|
172
|
+
pageUrl,
|
|
173
|
+
requestUrl,
|
|
174
|
+
source: "fetch",
|
|
175
|
+
startOffsetMs: clampOffset(requestStartedAtMs - replayStartedAtMs),
|
|
176
|
+
statusCode: null,
|
|
177
|
+
statusText: error.message || "network error",
|
|
178
|
+
transferSize: null,
|
|
179
|
+
});
|
|
180
|
+
throw cause;
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
};
|
|
184
|
+
cleanups.push(() => {
|
|
185
|
+
window.fetch = originalFetch;
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return () => {
|
|
189
|
+
for (const cleanup of cleanups) {
|
|
190
|
+
cleanup();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
}
|
package/dist/replay-privacy.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { ReplayPrivacyConfig } from "./types";
|
|
2
|
-
type ReplayRecordFn = typeof import("rrweb")["record"];
|
|
1
|
+
import type { ReplayPrivacyConfig } from "./types.js";
|
|
2
|
+
type ReplayRecordFn = (typeof import("rrweb"))["record"];
|
|
3
3
|
type ReplayRecordOptions = NonNullable<Parameters<ReplayRecordFn>[0]>;
|
|
4
4
|
type ReplayEmit = NonNullable<ReplayRecordOptions["emit"]>;
|
|
5
5
|
type ReplayEvent = Parameters<ReplayEmit>[0];
|
|
@@ -13,4 +13,3 @@ export declare function buildReplayRecordOptions(input: {
|
|
|
13
13
|
}): ReplayPrivacyRecordOptions;
|
|
14
14
|
export declare function applyReplayEventPrivacy<T extends ReplayEvent>(event: T, privacy?: ReplayPrivacyConfig): T;
|
|
15
15
|
export {};
|
|
16
|
-
//# sourceMappingURL=replay-privacy.d.ts.map
|
package/dist/replay-privacy.js
CHANGED
|
@@ -7,9 +7,16 @@ const MASKED_TEXT_KEYS = new Set([
|
|
|
7
7
|
"title",
|
|
8
8
|
"value",
|
|
9
9
|
]);
|
|
10
|
+
// Force rrweb to emit a fresh Meta+FullSnapshot every minute. Without this,
|
|
11
|
+
// only the first segment of a session contains a snapshot, so any later
|
|
12
|
+
// segment loss (or seeking to a non-zero anchor) leaves the player with
|
|
13
|
+
// nothing to render. 60s matches Sentry's buffer-mode checkout cadence and
|
|
14
|
+
// keeps each segment independently seekable.
|
|
15
|
+
const CHECKOUT_EVERY_NMS = 60_000;
|
|
10
16
|
export function buildReplayRecordOptions(input) {
|
|
11
17
|
const options = {
|
|
12
18
|
emit: input.emit,
|
|
19
|
+
checkoutEveryNms: CHECKOUT_EVERY_NMS,
|
|
13
20
|
};
|
|
14
21
|
if (input.privacy?.blockSelector) {
|
|
15
22
|
options.blockSelector = input.privacy.blockSelector;
|
|
@@ -25,36 +32,59 @@ export function applyReplayEventPrivacy(event, privacy) {
|
|
|
25
32
|
if (!privacy?.maskAllText) {
|
|
26
33
|
return event;
|
|
27
34
|
}
|
|
28
|
-
|
|
35
|
+
redactReplayValue(event, {
|
|
29
36
|
currentTagName: null,
|
|
30
37
|
key: "",
|
|
31
38
|
});
|
|
39
|
+
return event;
|
|
32
40
|
}
|
|
33
41
|
function redactReplayValue(value, context) {
|
|
34
|
-
if (typeof value === "string") {
|
|
35
|
-
return shouldMaskString(context) ? maskReplayText(value) : value;
|
|
36
|
-
}
|
|
37
42
|
if (Array.isArray(value)) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
43
|
+
value.forEach((entry, index) => {
|
|
44
|
+
if (isString(entry) && shouldMaskString(context)) {
|
|
45
|
+
value.splice(index, 1, maskReplayText(String(entry)));
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
redactReplayValue(entry, context);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return;
|
|
42
52
|
}
|
|
53
|
+
if (!isReplayRecord(value))
|
|
54
|
+
return;
|
|
43
55
|
const nextTagName = readTagName(value) ?? context.currentTagName;
|
|
44
|
-
const nextValue = {};
|
|
45
56
|
for (const [entryKey, entryValue] of Object.entries(value)) {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
57
|
+
const nextContext = { currentTagName: nextTagName, key: entryKey };
|
|
58
|
+
if (isString(entryValue) && shouldMaskString(nextContext)) {
|
|
59
|
+
Object.defineProperty(value, entryKey, {
|
|
60
|
+
configurable: true,
|
|
61
|
+
enumerable: true,
|
|
62
|
+
value: maskReplayText(String(entryValue)),
|
|
63
|
+
writable: true,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
redactReplayValue(entryValue, nextContext);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function isReplayRecord(value) {
|
|
72
|
+
if (value === null)
|
|
73
|
+
return false;
|
|
74
|
+
try {
|
|
75
|
+
const prototype = Object.getPrototypeOf(Object(value));
|
|
76
|
+
return prototype === null || Object.getPrototypeOf(prototype) === null;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
50
80
|
}
|
|
51
|
-
return nextValue;
|
|
52
81
|
}
|
|
53
|
-
function
|
|
54
|
-
return
|
|
82
|
+
function isString(value) {
|
|
83
|
+
return Object.prototype.toString.call(value) === "[object String]";
|
|
55
84
|
}
|
|
56
85
|
function readTagName(value) {
|
|
57
|
-
|
|
86
|
+
const tagName = Object.getOwnPropertyDescriptor(Object(value), "tagName")?.value;
|
|
87
|
+
return isString(tagName) ? String(tagName).toLowerCase() : null;
|
|
58
88
|
}
|
|
59
89
|
function maskReplayText(value) {
|
|
60
90
|
return value.replace(/[^\s]/g, "*");
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
interface ReplayUploadClient {
|
|
2
|
+
url: string;
|
|
3
|
+
headerKey: string;
|
|
4
|
+
debug: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* In-memory retry queue for replay payloads.
|
|
8
|
+
*
|
|
9
|
+
* Semantics:
|
|
10
|
+
* - Enqueue a payload; `flush()` tries to send the oldest pending job.
|
|
11
|
+
* - Successful 2xx -> drop the job.
|
|
12
|
+
* - 4xx (except 429) -> drop the job (non-retryable client error).
|
|
13
|
+
* - 5xx, 429, or network error -> increment attempts; reschedule with
|
|
14
|
+
* exponential backoff (1s, 2s, 4s). After MAX_ATTEMPTS, the job is dropped.
|
|
15
|
+
* - Concurrency: a `sending` flag prevents two concurrent `flush` runs from
|
|
16
|
+
* double-sending the same head-of-queue job.
|
|
17
|
+
* - Queue length is capped at MAX_PENDING_PAYLOADS to bound memory.
|
|
18
|
+
*/
|
|
19
|
+
export declare class ReplayUploader {
|
|
20
|
+
private readonly client;
|
|
21
|
+
private readonly queue;
|
|
22
|
+
private sending;
|
|
23
|
+
private activeController;
|
|
24
|
+
private retryTimer;
|
|
25
|
+
private disposed;
|
|
26
|
+
constructor(client: ReplayUploadClient);
|
|
27
|
+
enqueue(body: string): void;
|
|
28
|
+
/**
|
|
29
|
+
* Returns every payload body and clears the queue. The active fetch is
|
|
30
|
+
* aborted first so pagehide can transfer ownership of the head payload to
|
|
31
|
+
* sendBeacon instead of losing the initial replay snapshot on navigation.
|
|
32
|
+
*/
|
|
33
|
+
drain(): string[];
|
|
34
|
+
private oldestDroppableIndex;
|
|
35
|
+
dispose(): void;
|
|
36
|
+
flush(): Promise<void>;
|
|
37
|
+
private scheduleRetry;
|
|
38
|
+
private send;
|
|
39
|
+
}
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { attemptAsync } from "./_vendor/result-runtime/attempt.js";
|
|
2
|
+
import { getDefaultHeaders, nextBackoffMs } from "./_vendor/sdk-runtime/runtime/public-sdk-runtime.js";
|
|
3
|
+
const MAX_ATTEMPTS = 3;
|
|
4
|
+
const MAX_BACKOFF_MS = 4_000;
|
|
5
|
+
const MAX_PENDING_PAYLOADS = 10;
|
|
6
|
+
const SDK_NAME = "@squasher-ai/browser";
|
|
7
|
+
const SDK_VERSION = "0.3.0";
|
|
8
|
+
const DEFAULT_TELEMETRY_HEADERS = getDefaultHeaders({
|
|
9
|
+
packageName: SDK_NAME,
|
|
10
|
+
packageVersion: SDK_VERSION,
|
|
11
|
+
});
|
|
12
|
+
/**
|
|
13
|
+
* Compute next-attempt backoff. Delegates the math + Retry-After parsing to
|
|
14
|
+
* sdk-core's `nextBackoffMs` so every Squasher SDK shares one
|
|
15
|
+
* implementation. We keep the replay-specific 4-second cap because replay
|
|
16
|
+
* payloads are bursty and a longer wait throws off the page-unload window.
|
|
17
|
+
*/
|
|
18
|
+
function backoffForAttempt(attempts, headers) {
|
|
19
|
+
// sdk-core uses 0-indexed attempts; this module uses 1-indexed (1st retry).
|
|
20
|
+
return Math.round(nextBackoffMs({
|
|
21
|
+
attempt: attempts - 1,
|
|
22
|
+
headers: headers ?? undefined,
|
|
23
|
+
maxSleepMs: MAX_BACKOFF_MS,
|
|
24
|
+
}));
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* In-memory retry queue for replay payloads.
|
|
28
|
+
*
|
|
29
|
+
* Semantics:
|
|
30
|
+
* - Enqueue a payload; `flush()` tries to send the oldest pending job.
|
|
31
|
+
* - Successful 2xx -> drop the job.
|
|
32
|
+
* - 4xx (except 429) -> drop the job (non-retryable client error).
|
|
33
|
+
* - 5xx, 429, or network error -> increment attempts; reschedule with
|
|
34
|
+
* exponential backoff (1s, 2s, 4s). After MAX_ATTEMPTS, the job is dropped.
|
|
35
|
+
* - Concurrency: a `sending` flag prevents two concurrent `flush` runs from
|
|
36
|
+
* double-sending the same head-of-queue job.
|
|
37
|
+
* - Queue length is capped at MAX_PENDING_PAYLOADS to bound memory.
|
|
38
|
+
*/
|
|
39
|
+
export class ReplayUploader {
|
|
40
|
+
client;
|
|
41
|
+
queue = [];
|
|
42
|
+
sending = false;
|
|
43
|
+
activeController = null;
|
|
44
|
+
retryTimer = null;
|
|
45
|
+
disposed = false;
|
|
46
|
+
constructor(client) {
|
|
47
|
+
this.client = client;
|
|
48
|
+
}
|
|
49
|
+
enqueue(body) {
|
|
50
|
+
if (this.disposed)
|
|
51
|
+
return;
|
|
52
|
+
if (this.queue.length >= MAX_PENDING_PAYLOADS) {
|
|
53
|
+
if (this.client.debug) {
|
|
54
|
+
console.warn("[squasher] Replay retry queue full; dropping oldest payload");
|
|
55
|
+
}
|
|
56
|
+
this.queue.splice(this.oldestDroppableIndex(), 1);
|
|
57
|
+
}
|
|
58
|
+
this.queue.push({ body, attempts: 0 });
|
|
59
|
+
void this.flush();
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Returns every payload body and clears the queue. The active fetch is
|
|
63
|
+
* aborted first so pagehide can transfer ownership of the head payload to
|
|
64
|
+
* sendBeacon instead of losing the initial replay snapshot on navigation.
|
|
65
|
+
*/
|
|
66
|
+
drain() {
|
|
67
|
+
this.activeController?.abort();
|
|
68
|
+
this.activeController = null;
|
|
69
|
+
const bodies = this.queue.map((job) => job.body);
|
|
70
|
+
this.queue.length = 0;
|
|
71
|
+
if (this.retryTimer) {
|
|
72
|
+
clearTimeout(this.retryTimer);
|
|
73
|
+
this.retryTimer = null;
|
|
74
|
+
}
|
|
75
|
+
return bodies;
|
|
76
|
+
}
|
|
77
|
+
oldestDroppableIndex() {
|
|
78
|
+
return this.sending && this.queue.length > 1 ? 1 : 0;
|
|
79
|
+
}
|
|
80
|
+
dispose() {
|
|
81
|
+
this.disposed = true;
|
|
82
|
+
this.activeController?.abort();
|
|
83
|
+
this.activeController = null;
|
|
84
|
+
this.queue.length = 0;
|
|
85
|
+
if (this.retryTimer) {
|
|
86
|
+
clearTimeout(this.retryTimer);
|
|
87
|
+
this.retryTimer = null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
async flush() {
|
|
91
|
+
if (this.sending || this.disposed)
|
|
92
|
+
return;
|
|
93
|
+
if (this.queue.length === 0)
|
|
94
|
+
return;
|
|
95
|
+
if (this.retryTimer)
|
|
96
|
+
return; // waiting for scheduled retry
|
|
97
|
+
this.sending = true;
|
|
98
|
+
try {
|
|
99
|
+
const job = this.queue[0];
|
|
100
|
+
if (!job)
|
|
101
|
+
return;
|
|
102
|
+
const controller = new AbortController();
|
|
103
|
+
this.activeController = controller;
|
|
104
|
+
const outcome = await this.send(job.body, controller.signal);
|
|
105
|
+
if (this.activeController === controller) {
|
|
106
|
+
this.activeController = null;
|
|
107
|
+
}
|
|
108
|
+
if (this.queue[0] === job) {
|
|
109
|
+
if (outcome.kind === "success") {
|
|
110
|
+
this.queue.shift();
|
|
111
|
+
}
|
|
112
|
+
else if (outcome.kind === "drop") {
|
|
113
|
+
this.queue.shift();
|
|
114
|
+
if (this.client.debug) {
|
|
115
|
+
console.warn(`[squasher] Replay upload failed with non-retryable status; dropping payload (${outcome.reason})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
job.attempts += 1;
|
|
120
|
+
if (job.attempts >= MAX_ATTEMPTS) {
|
|
121
|
+
this.queue.shift();
|
|
122
|
+
if (this.client.debug) {
|
|
123
|
+
console.warn(`[squasher] Replay upload exhausted ${MAX_ATTEMPTS} attempts; dropping payload`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
else {
|
|
127
|
+
const delay = backoffForAttempt(job.attempts, outcome.retryHeaders);
|
|
128
|
+
this.scheduleRetry(delay);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
this.sending = false;
|
|
136
|
+
}
|
|
137
|
+
if (this.queue.length > 0 && !this.retryTimer) {
|
|
138
|
+
void this.flush();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
scheduleRetry(delayMs) {
|
|
142
|
+
if (this.retryTimer)
|
|
143
|
+
return;
|
|
144
|
+
this.retryTimer = setTimeout(() => {
|
|
145
|
+
this.retryTimer = null;
|
|
146
|
+
void this.flush();
|
|
147
|
+
}, delayMs);
|
|
148
|
+
}
|
|
149
|
+
async send(body, signal) {
|
|
150
|
+
const result = await attemptAsync({
|
|
151
|
+
try: async () => {
|
|
152
|
+
const response = await fetch(this.client.url, {
|
|
153
|
+
method: "POST",
|
|
154
|
+
headers: {
|
|
155
|
+
...DEFAULT_TELEMETRY_HEADERS,
|
|
156
|
+
"Content-Type": "application/json",
|
|
157
|
+
"x-squasher-key": this.client.headerKey,
|
|
158
|
+
},
|
|
159
|
+
body,
|
|
160
|
+
signal,
|
|
161
|
+
});
|
|
162
|
+
return response;
|
|
163
|
+
},
|
|
164
|
+
catch: ({ cause }) => {
|
|
165
|
+
if (this.client.debug) {
|
|
166
|
+
console.warn("[squasher] Replay upload network error:", cause);
|
|
167
|
+
}
|
|
168
|
+
return null;
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
if (!result) {
|
|
172
|
+
return { kind: "retry", retryHeaders: null };
|
|
173
|
+
}
|
|
174
|
+
if (result.status >= 200 && result.status < 300) {
|
|
175
|
+
if (this.client.debug) {
|
|
176
|
+
console.log("[squasher] Replay events sent via fetch");
|
|
177
|
+
}
|
|
178
|
+
return { kind: "success" };
|
|
179
|
+
}
|
|
180
|
+
if (result.status === 429) {
|
|
181
|
+
// Hand the response Headers straight to sdk-core's nextBackoffMs so it
|
|
182
|
+
// can read Retry-After-Ms or Retry-After (seconds or HTTP-date).
|
|
183
|
+
return {
|
|
184
|
+
kind: "retry",
|
|
185
|
+
retryHeaders: result.headers,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
if (result.status >= 500) {
|
|
189
|
+
return { kind: "retry", retryHeaders: null };
|
|
190
|
+
}
|
|
191
|
+
return { kind: "drop", reason: `HTTP ${result.status}` };
|
|
192
|
+
}
|
|
193
|
+
}
|