@sailfish-ai/recorder 1.7.47 → 1.7.50
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/inAppReportIssueModal.js +174 -4
- package/dist/index.js +11 -243
- package/dist/runtimeEnv.js +7 -0
- package/dist/sailfish-recorder.cjs.js +1 -1
- package/dist/sailfish-recorder.cjs.js.br +0 -0
- package/dist/sailfish-recorder.cjs.js.gz +0 -0
- package/dist/sailfish-recorder.es.js +1 -1
- package/dist/sailfish-recorder.es.js.br +0 -0
- package/dist/sailfish-recorder.es.js.gz +0 -0
- package/dist/sailfish-recorder.umd.js +1 -1
- package/dist/sailfish-recorder.umd.js.br +0 -0
- package/dist/sailfish-recorder.umd.js.gz +0 -0
- package/dist/session.js +12 -0
- package/dist/types/inAppReportIssueModal.d.ts +3 -1
- package/dist/types/index.d.ts +1 -11
- package/dist/types/runtimeEnv.d.ts +4 -0
- package/dist/types/session.d.ts +1 -0
- package/dist/types/utils.d.ts +1 -0
- package/dist/utils.js +6 -0
- package/dist/websocket.js +19 -6
- package/package.json +1 -1
|
@@ -15,6 +15,7 @@ export const ReportIssueContext = {
|
|
|
15
15
|
apiKey: null,
|
|
16
16
|
backendApi: null,
|
|
17
17
|
triageBaseUrl: "https://app.sailfishqa.com",
|
|
18
|
+
deactivateIsolation: () => { },
|
|
18
19
|
};
|
|
19
20
|
let modalEl = null;
|
|
20
21
|
let currentState = {
|
|
@@ -94,6 +95,8 @@ export function setupIssueReporting(options) {
|
|
|
94
95
|
ReportIssueContext.apiKey = options.apiKey;
|
|
95
96
|
ReportIssueContext.backendApi = options.backendApi;
|
|
96
97
|
ReportIssueContext.resolveSessionId = options.getSessionId;
|
|
98
|
+
if (options.customBaseUrl)
|
|
99
|
+
ReportIssueContext.triageBaseUrl = options.customBaseUrl;
|
|
97
100
|
ReportIssueContext.shortcuts = mergeShortcutsConfig(options.shortcuts);
|
|
98
101
|
const { shortcuts } = ReportIssueContext;
|
|
99
102
|
window.addEventListener("keydown", (e) => {
|
|
@@ -165,7 +168,7 @@ function getSessionIdSafely() {
|
|
|
165
168
|
}
|
|
166
169
|
return ReportIssueContext.resolveSessionId();
|
|
167
170
|
}
|
|
168
|
-
export function openReportIssueModal(
|
|
171
|
+
export function openReportIssueModal() {
|
|
169
172
|
if (isRecording) {
|
|
170
173
|
stopRecording();
|
|
171
174
|
return;
|
|
@@ -173,11 +176,9 @@ export function openReportIssueModal(customBaseUrl) {
|
|
|
173
176
|
injectModalHTML();
|
|
174
177
|
if (modalEl)
|
|
175
178
|
document.body.appendChild(modalEl);
|
|
176
|
-
// Store custom base URL globally for reuse
|
|
177
|
-
ReportIssueContext.triageBaseUrl =
|
|
178
|
-
customBaseUrl ?? "https://app.sailfishqa.com";
|
|
179
179
|
}
|
|
180
180
|
function closeModal() {
|
|
181
|
+
ReportIssueContext.deactivateIsolation();
|
|
181
182
|
// ✅ Explicitly blur the focused element
|
|
182
183
|
if (document.activeElement instanceof HTMLElement) {
|
|
183
184
|
document.activeElement.blur();
|
|
@@ -198,6 +199,174 @@ function closeModal() {
|
|
|
198
199
|
if (timerInterval)
|
|
199
200
|
clearInterval(timerInterval);
|
|
200
201
|
}
|
|
202
|
+
function activateModalIsolation(modal) {
|
|
203
|
+
// A. Ensure modal is a proper dialog & focus anchor
|
|
204
|
+
modal.setAttribute("role", "dialog");
|
|
205
|
+
modal.setAttribute("aria-modal", "true");
|
|
206
|
+
if (!modal.hasAttribute("tabindex"))
|
|
207
|
+
modal.setAttribute("tabindex", "-1");
|
|
208
|
+
// What element we want to keep focused
|
|
209
|
+
const focusEl = modal.querySelector("#sf-issue-description") ||
|
|
210
|
+
modal.querySelector("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])") ||
|
|
211
|
+
modal;
|
|
212
|
+
// Put sentinels to keep Tab inside
|
|
213
|
+
const startSentinel = document.createElement("div");
|
|
214
|
+
const endSentinel = document.createElement("div");
|
|
215
|
+
startSentinel.tabIndex = 0;
|
|
216
|
+
endSentinel.tabIndex = 0;
|
|
217
|
+
startSentinel.style.position = endSentinel.style.position = "fixed"; // not visible
|
|
218
|
+
startSentinel.style.width = endSentinel.style.width = "1px";
|
|
219
|
+
startSentinel.style.height = endSentinel.style.height = "1px";
|
|
220
|
+
startSentinel.style.outline = endSentinel.style.outline = "none";
|
|
221
|
+
modal.prepend(startSentinel);
|
|
222
|
+
modal.append(endSentinel);
|
|
223
|
+
const getFocusable = () => Array.from(modal.querySelectorAll("button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])")).filter((el) => !el.hasAttribute("disabled") && el.offsetParent !== null);
|
|
224
|
+
const cycleFocus = (forward) => {
|
|
225
|
+
const list = getFocusable();
|
|
226
|
+
if (list.length === 0)
|
|
227
|
+
return;
|
|
228
|
+
const target = forward ? list[0] : list[list.length - 1];
|
|
229
|
+
target.focus({ preventScroll: true });
|
|
230
|
+
};
|
|
231
|
+
startSentinel.addEventListener("focus", () => cycleFocus(false));
|
|
232
|
+
endSentinel.addEventListener("focus", () => cycleFocus(true));
|
|
233
|
+
// B. Event quarantine: block interactions outside modal
|
|
234
|
+
const quarantine = (e) => {
|
|
235
|
+
const t = e.target;
|
|
236
|
+
if (!t)
|
|
237
|
+
return;
|
|
238
|
+
if (!modal.contains(t)) {
|
|
239
|
+
// Stop any event that could lead to focus shift or interaction
|
|
240
|
+
e.stopImmediatePropagation();
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
// Snap focus back inside immediately
|
|
243
|
+
refocus();
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
// We capture broadly to beat most libs
|
|
247
|
+
const quarantineEvents = [
|
|
248
|
+
"mousedown",
|
|
249
|
+
"mouseup",
|
|
250
|
+
"click",
|
|
251
|
+
"pointerdown",
|
|
252
|
+
"pointerup",
|
|
253
|
+
"touchstart",
|
|
254
|
+
"touchend",
|
|
255
|
+
"wheel",
|
|
256
|
+
"keydown",
|
|
257
|
+
"keyup",
|
|
258
|
+
"focus",
|
|
259
|
+
"focusin",
|
|
260
|
+
"focusout",
|
|
261
|
+
"blur",
|
|
262
|
+
];
|
|
263
|
+
quarantineEvents.forEach((type) => document.addEventListener(type, quarantine, true));
|
|
264
|
+
// C. Focus watchdog: if *anything* steals focus (even iframes), snap back.
|
|
265
|
+
let rafId = 0;
|
|
266
|
+
let lastSelection = null;
|
|
267
|
+
const textarea = focusEl instanceof HTMLTextAreaElement ? focusEl : null;
|
|
268
|
+
// Track caret to restore it when we yank focus back
|
|
269
|
+
const trackCaret = () => {
|
|
270
|
+
if (textarea && document.activeElement === textarea) {
|
|
271
|
+
try {
|
|
272
|
+
lastSelection = {
|
|
273
|
+
start: textarea.selectionStart ?? textarea.value.length,
|
|
274
|
+
end: textarea.selectionEnd ?? textarea.value.length,
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
catch {
|
|
278
|
+
// ignore
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
const refocus = () => {
|
|
283
|
+
const target = modal.querySelector(":focus") ||
|
|
284
|
+
textarea ||
|
|
285
|
+
focusEl ||
|
|
286
|
+
modal;
|
|
287
|
+
// Put focus back
|
|
288
|
+
target.focus({ preventScroll: true });
|
|
289
|
+
// Restore caret if possible
|
|
290
|
+
if (textarea && document.activeElement === textarea && lastSelection) {
|
|
291
|
+
try {
|
|
292
|
+
textarea.setSelectionRange(lastSelection.start, lastSelection.end, "none");
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
// ignore
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
// NEW: selection-aware helper (don’t refocus if selection lives inside modal)
|
|
300
|
+
const selectionInsideModal = () => {
|
|
301
|
+
const sel = typeof document.getSelection === "function"
|
|
302
|
+
? document.getSelection()
|
|
303
|
+
: null;
|
|
304
|
+
if (!sel)
|
|
305
|
+
return false;
|
|
306
|
+
// check anchor/focus nodes first
|
|
307
|
+
const nodes = [sel.anchorNode, sel.focusNode].filter(Boolean);
|
|
308
|
+
if (nodes.some((n) => modal.contains(n)))
|
|
309
|
+
return true;
|
|
310
|
+
// fallback to range container if available
|
|
311
|
+
if (sel.rangeCount > 0) {
|
|
312
|
+
const container = sel.getRangeAt(0).commonAncestorContainer;
|
|
313
|
+
if (container &&
|
|
314
|
+
modal.contains(container.nodeType === 3 ? container.parentNode : container)) {
|
|
315
|
+
return true;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
return false;
|
|
319
|
+
};
|
|
320
|
+
// NEW: tiny hysteresis to ignore transient focus blips
|
|
321
|
+
const ESCAPED_FRAMES_THRESHOLD = 2;
|
|
322
|
+
let escapedFrames = 0;
|
|
323
|
+
const watchdog = () => {
|
|
324
|
+
const ae = document.activeElement;
|
|
325
|
+
const escaped = ae === document.body || ae == null || !modal.contains(ae);
|
|
326
|
+
const selectingInside = selectionInsideModal();
|
|
327
|
+
if (escaped && !selectingInside) {
|
|
328
|
+
escapedFrames += 1;
|
|
329
|
+
if (escapedFrames >= ESCAPED_FRAMES_THRESHOLD) {
|
|
330
|
+
refocus();
|
|
331
|
+
escapedFrames = 0;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
escapedFrames = 0;
|
|
336
|
+
trackCaret();
|
|
337
|
+
}
|
|
338
|
+
rafId = window.requestAnimationFrame(watchdog);
|
|
339
|
+
};
|
|
340
|
+
rafId = window.requestAnimationFrame(watchdog);
|
|
341
|
+
// Also bounce on immediate blur/focusout — but skip if selection is inside modal
|
|
342
|
+
const onBlurLike = () => {
|
|
343
|
+
// Let the blur settle, then snap back if truly escaped
|
|
344
|
+
setTimeout(() => {
|
|
345
|
+
const ae = document.activeElement;
|
|
346
|
+
const escaped = ae === document.body || ae == null || !modal.contains(ae);
|
|
347
|
+
if (escaped && !selectionInsideModal()) {
|
|
348
|
+
refocus();
|
|
349
|
+
}
|
|
350
|
+
}, 0);
|
|
351
|
+
};
|
|
352
|
+
window.addEventListener("blur", onBlurLike, true);
|
|
353
|
+
document.addEventListener("focusout", onBlurLike, true);
|
|
354
|
+
// Initial focus
|
|
355
|
+
setTimeout(() => focusEl.focus({ preventScroll: true }), 0);
|
|
356
|
+
// Cleanup
|
|
357
|
+
return () => {
|
|
358
|
+
try {
|
|
359
|
+
startSentinel.remove();
|
|
360
|
+
endSentinel.remove();
|
|
361
|
+
}
|
|
362
|
+
catch { }
|
|
363
|
+
quarantineEvents.forEach((type) => document.removeEventListener(type, quarantine, true));
|
|
364
|
+
window.removeEventListener("blur", onBlurLike, true);
|
|
365
|
+
document.removeEventListener("focusout", onBlurLike, true);
|
|
366
|
+
if (rafId)
|
|
367
|
+
cancelAnimationFrame(rafId);
|
|
368
|
+
};
|
|
369
|
+
}
|
|
201
370
|
function injectModalHTML(initialMode = "lookback") {
|
|
202
371
|
if (modalEl) {
|
|
203
372
|
modalEl.remove();
|
|
@@ -349,6 +518,7 @@ function injectModalHTML(initialMode = "lookback") {
|
|
|
349
518
|
currentState.mode = initialMode;
|
|
350
519
|
document.body.appendChild(modalEl);
|
|
351
520
|
bindListeners();
|
|
521
|
+
ReportIssueContext.deactivateIsolation = activateModalIsolation(modalEl);
|
|
352
522
|
}
|
|
353
523
|
function setActiveTab(mode) {
|
|
354
524
|
currentState.mode = mode;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,4 @@
|
|
|
1
1
|
const DEBUG = import.meta.env.VITE_DEBUG ? import.meta.env.VITE_DEBUG : false;
|
|
2
|
-
// ── SSR guards ─────────────────────────────────────────
|
|
3
|
-
const HAS_WINDOW = typeof globalThis !== "undefined" &&
|
|
4
|
-
typeof globalThis.window !== "undefined";
|
|
5
|
-
const HAS_DOCUMENT = typeof globalThis !== "undefined" &&
|
|
6
|
-
typeof globalThis.document !== "undefined";
|
|
7
|
-
const HAS_LOCAL_STORAGE = typeof globalThis !== "undefined" && "localStorage" in globalThis;
|
|
8
|
-
const HAS_SESSION_STORAGE = typeof globalThis !== "undefined" && "sessionStorage" in globalThis;
|
|
9
2
|
// import { NetworkRecordOptions } from "@sailfish-rrweb/rrweb-plugin-network-record";
|
|
10
3
|
import { v4 as uuidv4 } from "uuid";
|
|
11
4
|
import { NetworkRequestEventId, STATIC_EXTENSIONS, xSf3RidHeader, } from "./constants";
|
|
@@ -14,6 +7,9 @@ import { fetchCaptureSettings, sendDomainsToNotPropagateHeaderTo, startRecording
|
|
|
14
7
|
import { setupIssueReporting } from "./inAppReportIssueModal";
|
|
15
8
|
import { sendMapUuidIfAvailable } from "./mapUuid";
|
|
16
9
|
import { getUrlAndStoredUuids, initializeConsolePlugin, initializeDomContentEvents, initializeRecording, } from "./recording";
|
|
10
|
+
import { HAS_DOCUMENT, HAS_LOCAL_STORAGE, HAS_SESSION_STORAGE, HAS_WINDOW, } from "./runtimeEnv";
|
|
11
|
+
import { getOrSetSessionId } from "./session";
|
|
12
|
+
import { withAppUrlMetadata } from "./utils";
|
|
17
13
|
import { sendEvent, sendMessage } from "./websocket";
|
|
18
14
|
// Default list of domains to ignore
|
|
19
15
|
const DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT = [
|
|
@@ -33,234 +29,12 @@ const BAD_HTTP_STATUS = [
|
|
|
33
29
|
];
|
|
34
30
|
const CORS_KEYWORD = "CORS";
|
|
35
31
|
export const STORAGE_VERSION = 1;
|
|
36
|
-
export const DYNAMIC_PASSED_HOSTS_KEY = "dynamicPassedHosts";
|
|
37
|
-
export const DYNAMIC_EXCLUDED_HOSTS_KEY = "dynamicExcludedHosts";
|
|
38
32
|
const SF_API_KEY_FOR_UPDATE = "sailfishApiKey";
|
|
39
33
|
const SF_BACKEND_API = "sailfishBackendApi";
|
|
40
34
|
const INCLUDE = "include";
|
|
41
35
|
const SAME_ORIGIN = "same-origin";
|
|
42
36
|
const ALLOWED_HEADERS_HEADER = "access-control-allow-headers";
|
|
43
37
|
const OPTIONS = "OPTIONS";
|
|
44
|
-
export const dynamicExcludedHosts = new Set();
|
|
45
|
-
export const dynamicPassedHosts = new Set();
|
|
46
|
-
// 1️⃣ dynamicExcludedHosts.add override
|
|
47
|
-
const originalExcludedAdd = dynamicExcludedHosts.add;
|
|
48
|
-
dynamicExcludedHosts.add = (host) => {
|
|
49
|
-
const cleaned = host?.trim();
|
|
50
|
-
if (!cleaned || dynamicExcludedHosts.has(cleaned)) {
|
|
51
|
-
return dynamicExcludedHosts;
|
|
52
|
-
}
|
|
53
|
-
// 1. Add to the excluded Set
|
|
54
|
-
originalExcludedAdd.call(dynamicExcludedHosts, cleaned);
|
|
55
|
-
// 2. If it was previously in passed, remove it
|
|
56
|
-
if (dynamicPassedHosts.has(cleaned)) {
|
|
57
|
-
dynamicPassedHosts.delete(cleaned);
|
|
58
|
-
}
|
|
59
|
-
// 3. Consolidate dynamic exclusions into smart wildcards
|
|
60
|
-
const consolidated = consolidateDynamicExclusions(dynamicExcludedHosts);
|
|
61
|
-
dynamicExcludedHosts.clear();
|
|
62
|
-
for (const p of consolidated) {
|
|
63
|
-
originalExcludedAdd.call(dynamicExcludedHosts, p);
|
|
64
|
-
}
|
|
65
|
-
// 4. Persist wrapper to localStorage
|
|
66
|
-
try {
|
|
67
|
-
if (HAS_LOCAL_STORAGE) {
|
|
68
|
-
// TODO decouple into two logical pieces
|
|
69
|
-
const wrapper = {
|
|
70
|
-
version: STORAGE_VERSION,
|
|
71
|
-
entries: {},
|
|
72
|
-
};
|
|
73
|
-
const now = Date.now();
|
|
74
|
-
for (const p of dynamicExcludedHosts) {
|
|
75
|
-
wrapper.entries[p] = now;
|
|
76
|
-
}
|
|
77
|
-
localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify(wrapper));
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
catch (e) {
|
|
81
|
-
if (DEBUG)
|
|
82
|
-
console.warn("Persist dynamicExcludedHosts failed:", e);
|
|
83
|
-
}
|
|
84
|
-
// 5. Notify backend of the updated Set
|
|
85
|
-
updateExcludedHostsStorageAndBackend(dynamicExcludedHosts);
|
|
86
|
-
return dynamicExcludedHosts;
|
|
87
|
-
};
|
|
88
|
-
// 2️⃣ dynamicPassedHosts.add override (timestamps refreshed on every call)
|
|
89
|
-
const originalPassedAdd = dynamicPassedHosts.add;
|
|
90
|
-
dynamicPassedHosts.add = (host) => {
|
|
91
|
-
const cleaned = host?.trim();
|
|
92
|
-
if (!cleaned) {
|
|
93
|
-
return dynamicPassedHosts;
|
|
94
|
-
}
|
|
95
|
-
// 1. Persist wrapper to localStorage (update timestamp unconditionally)
|
|
96
|
-
try {
|
|
97
|
-
if (HAS_LOCAL_STORAGE) {
|
|
98
|
-
// TODO decouple into two logical pieces
|
|
99
|
-
const raw = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
|
|
100
|
-
let wrapper;
|
|
101
|
-
if (!raw) {
|
|
102
|
-
wrapper = { version: STORAGE_VERSION, entries: {} };
|
|
103
|
-
}
|
|
104
|
-
else {
|
|
105
|
-
const parsed = JSON.parse(raw);
|
|
106
|
-
if (Array.isArray(parsed)) {
|
|
107
|
-
// migrate old array → wrapper
|
|
108
|
-
wrapper = {
|
|
109
|
-
version: STORAGE_VERSION,
|
|
110
|
-
entries: Object.fromEntries(parsed.map((h) => [h, Date.now()])),
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
else if (parsed &&
|
|
114
|
-
typeof parsed === "object" &&
|
|
115
|
-
"entries" in parsed &&
|
|
116
|
-
typeof parsed.entries === "object") {
|
|
117
|
-
wrapper = parsed;
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
wrapper = { version: STORAGE_VERSION, entries: {} };
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
// always set/update the timestamp
|
|
124
|
-
wrapper.entries[cleaned] = Date.now();
|
|
125
|
-
wrapper.version = STORAGE_VERSION;
|
|
126
|
-
localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify(wrapper));
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
catch (e) {
|
|
130
|
-
if (DEBUG)
|
|
131
|
-
console.warn("Persist dynamicPassedHosts failed:", e);
|
|
132
|
-
}
|
|
133
|
-
// 2. Add to the passed Set if not already present
|
|
134
|
-
if (!dynamicPassedHosts.has(cleaned)) {
|
|
135
|
-
originalPassedAdd.call(dynamicPassedHosts, cleaned);
|
|
136
|
-
}
|
|
137
|
-
return dynamicPassedHosts;
|
|
138
|
-
};
|
|
139
|
-
/**
|
|
140
|
-
* Notify the backend of the updated dynamicExcludedHosts
|
|
141
|
-
*/
|
|
142
|
-
function updateExcludedHostsStorageAndBackend(dynamicExcludedHosts) {
|
|
143
|
-
if (!HAS_SESSION_STORAGE)
|
|
144
|
-
return;
|
|
145
|
-
const apiKeyForUpdate = sessionStorage.getItem(SF_API_KEY_FOR_UPDATE) || "";
|
|
146
|
-
const apiForUpdate = sessionStorage.getItem(SF_BACKEND_API) || "";
|
|
147
|
-
if (!apiForUpdate) {
|
|
148
|
-
return;
|
|
149
|
-
}
|
|
150
|
-
sendDomainsToNotPropagateHeaderTo(apiKeyForUpdate, [...dynamicExcludedHosts, ...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT], apiForUpdate).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
|
|
151
|
-
}
|
|
152
|
-
/**
|
|
153
|
-
* Given a set of excluded hostPaths (like "foo.com/bar", "foo.com/baz", etc.),
|
|
154
|
-
* find any path prefixes under each host that appear ≥ threshold times,
|
|
155
|
-
* and replace their individual entries with a single wildcard pattern
|
|
156
|
-
* (e.g. "foo.com/bar*").
|
|
157
|
-
*/
|
|
158
|
-
export function consolidateDynamicExclusions(hostPathSet, threshold = 3) {
|
|
159
|
-
// 1️⃣ Group by host
|
|
160
|
-
const byHost = {};
|
|
161
|
-
for (const hostPath of hostPathSet) {
|
|
162
|
-
const [host, ...rest] = hostPath.split("/");
|
|
163
|
-
const path = rest.length ? `/${rest.join("/")}` : "/";
|
|
164
|
-
(byHost[host] ??= []).push(path);
|
|
165
|
-
}
|
|
166
|
-
const newSet = new Set();
|
|
167
|
-
for (const host in byHost) {
|
|
168
|
-
const paths = byHost[host];
|
|
169
|
-
if (paths.length < threshold) {
|
|
170
|
-
// not enough entries to bother; keep them as-is
|
|
171
|
-
for (const p of paths)
|
|
172
|
-
newSet.add(`${host}${p}`);
|
|
173
|
-
continue;
|
|
174
|
-
}
|
|
175
|
-
const root = { count: 0, children: new Map() };
|
|
176
|
-
for (const p of paths) {
|
|
177
|
-
root.count++;
|
|
178
|
-
const segments = p.split("/").filter(Boolean);
|
|
179
|
-
let node = root;
|
|
180
|
-
for (const seg of segments) {
|
|
181
|
-
if (!node.children.has(seg)) {
|
|
182
|
-
node.children.set(seg, { count: 0, children: new Map() });
|
|
183
|
-
}
|
|
184
|
-
node = node.children.get(seg);
|
|
185
|
-
node.count++;
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
// 3️⃣ Walk the trie and pick prefixes where node.count ≥ threshold
|
|
189
|
-
function gather(node, prefixSegments) {
|
|
190
|
-
// if this node covers ≥ threshold of this host’s paths, collapse here
|
|
191
|
-
if (node.count >= threshold && prefixSegments.length > 0) {
|
|
192
|
-
const wildcardPath = "/" + prefixSegments.join("/") + "/*";
|
|
193
|
-
newSet.add(`${host}${wildcardPath}`);
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
// otherwise, recurse into children
|
|
197
|
-
for (const [seg, child] of node.children) {
|
|
198
|
-
gather(child, prefixSegments.concat(seg));
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
gather(root, []);
|
|
202
|
-
}
|
|
203
|
-
return newSet;
|
|
204
|
-
}
|
|
205
|
-
// 2️⃣ Load & evict old entries (>7 days) + version check for Excluded
|
|
206
|
-
if (HAS_LOCAL_STORAGE) {
|
|
207
|
-
(() => {
|
|
208
|
-
const stored = localStorage.getItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
|
|
209
|
-
if (!stored)
|
|
210
|
-
return;
|
|
211
|
-
try {
|
|
212
|
-
const wrapper = JSON.parse(stored);
|
|
213
|
-
// if it's from an old version, drop it
|
|
214
|
-
if (wrapper.version !== STORAGE_VERSION) {
|
|
215
|
-
localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
const now = Date.now();
|
|
219
|
-
const valid = {};
|
|
220
|
-
for (const [host, ts] of Object.entries(wrapper.entries)) {
|
|
221
|
-
if (now - ts < 7 * 24 * 60 * 60 * 1000) {
|
|
222
|
-
dynamicExcludedHosts.add(host);
|
|
223
|
-
valid[host] = ts;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
localStorage.setItem(DYNAMIC_EXCLUDED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
|
|
227
|
-
}
|
|
228
|
-
catch (e) {
|
|
229
|
-
if (DEBUG)
|
|
230
|
-
console.warn("Failed to parse dynamicExcludedHosts:", e);
|
|
231
|
-
localStorage.removeItem(DYNAMIC_EXCLUDED_HOSTS_KEY);
|
|
232
|
-
}
|
|
233
|
-
})();
|
|
234
|
-
}
|
|
235
|
-
// 3️⃣ Load & evict old entries (>7 days) + version check for Passed
|
|
236
|
-
if (HAS_LOCAL_STORAGE) {
|
|
237
|
-
(() => {
|
|
238
|
-
const stored = localStorage.getItem(DYNAMIC_PASSED_HOSTS_KEY);
|
|
239
|
-
if (!stored)
|
|
240
|
-
return;
|
|
241
|
-
try {
|
|
242
|
-
const wrapper = JSON.parse(stored);
|
|
243
|
-
if (wrapper.version !== STORAGE_VERSION) {
|
|
244
|
-
localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
const now = Date.now();
|
|
248
|
-
const valid = {};
|
|
249
|
-
for (const [host, ts] of Object.entries(wrapper.entries)) {
|
|
250
|
-
if (now - ts < 7 * 24 * 60 * 60 * 1000) {
|
|
251
|
-
dynamicPassedHosts.add(host);
|
|
252
|
-
valid[host] = ts;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
localStorage.setItem(DYNAMIC_PASSED_HOSTS_KEY, JSON.stringify({ version: STORAGE_VERSION, entries: valid }));
|
|
256
|
-
}
|
|
257
|
-
catch (e) {
|
|
258
|
-
if (DEBUG)
|
|
259
|
-
console.warn("Failed to parse dynamicPassedHosts:", e);
|
|
260
|
-
localStorage.removeItem(DYNAMIC_PASSED_HOSTS_KEY);
|
|
261
|
-
}
|
|
262
|
-
})();
|
|
263
|
-
}
|
|
264
38
|
const ActionType = {
|
|
265
39
|
PROPAGATE: "propagate",
|
|
266
40
|
IGNORE: "ignore",
|
|
@@ -323,7 +97,6 @@ function trackDomainChanges() {
|
|
|
323
97
|
timestamp,
|
|
324
98
|
page_visit_uuid: pageVisitUUID,
|
|
325
99
|
prev_page_visit_uuid: prevPageVisitUUID,
|
|
326
|
-
session_id: getOrSetSessionId(),
|
|
327
100
|
},
|
|
328
101
|
});
|
|
329
102
|
}
|
|
@@ -384,16 +157,6 @@ function getOrSetUserDeviceUuid() {
|
|
|
384
157
|
}
|
|
385
158
|
return userDeviceUuid;
|
|
386
159
|
}
|
|
387
|
-
// Storing the sailfishSessionId in window.name, as window.name retains its value after a page refresh
|
|
388
|
-
// but resets when a new tab (including a duplicated tab) is opened.
|
|
389
|
-
function getOrSetSessionId() {
|
|
390
|
-
if (!HAS_WINDOW)
|
|
391
|
-
return uuidv4();
|
|
392
|
-
if (!window.name) {
|
|
393
|
-
window.name = uuidv4();
|
|
394
|
-
}
|
|
395
|
-
return window.name;
|
|
396
|
-
}
|
|
397
160
|
// Function to handle resetting the sessionId when the page becomes visible again
|
|
398
161
|
function handleVisibilityChange() {
|
|
399
162
|
if (document.visibilityState === "visible") {
|
|
@@ -655,7 +418,6 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
655
418
|
else {
|
|
656
419
|
return target.apply(thisArg, args);
|
|
657
420
|
}
|
|
658
|
-
const domain = new URL(url, window.location.href).hostname;
|
|
659
421
|
// 2️⃣ Skip header injection if excluded
|
|
660
422
|
if (shouldSkipHeadersPropagation(url, domainsToNotPropagateHeadersTo)) {
|
|
661
423
|
return target.apply(thisArg, args);
|
|
@@ -761,7 +523,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = []) {
|
|
|
761
523
|
return await target.call(thisArg, input, modifiedInit);
|
|
762
524
|
}
|
|
763
525
|
}
|
|
764
|
-
// Helper to retry a fetch without the X-Sf3-Rid header
|
|
526
|
+
// Helper to retry a fetch without the X-Sf3-Rid header when we receive 400/403 errors
|
|
765
527
|
async function retryWithoutPropagateHeaders(target, thisArg, args, url) {
|
|
766
528
|
try {
|
|
767
529
|
// **Fix:** Properly await and clone the request without the tracing header
|
|
@@ -856,6 +618,10 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
|
|
|
856
618
|
trackDomainChanges();
|
|
857
619
|
sessionStorage.setItem(SF_API_KEY_FOR_UPDATE, apiKey);
|
|
858
620
|
sessionStorage.setItem(SF_BACKEND_API, backendApi);
|
|
621
|
+
sendDomainsToNotPropagateHeaderTo(apiKey, [
|
|
622
|
+
...domainsToNotPropagateHeaderTo,
|
|
623
|
+
...DOMAINS_TO_NOT_PROPAGATE_HEADER_TO_DEFAULT,
|
|
624
|
+
], backendApi).catch((error) => console.error("Failed to send domains to not propagate header to:", error));
|
|
859
625
|
// Setup interceptors with custom ignore and propagate domains
|
|
860
626
|
setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo);
|
|
861
627
|
setupFetchInterceptor(domainsToNotPropagateHeaderTo);
|
|
@@ -864,7 +630,8 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
|
|
|
864
630
|
const captureSettingsResponse = await fetchCaptureSettings(apiKey, backendApi);
|
|
865
631
|
const captureSettings = captureSettingsResponse.data?.captureSettingsFromApiKey ||
|
|
866
632
|
DEFAULT_CAPTURE_SETTINGS;
|
|
867
|
-
const
|
|
633
|
+
const metadataWithAppUrl = withAppUrlMetadata(serviceAdditionalMetadata);
|
|
634
|
+
const sessionResponse = await startRecordingSession(apiKey, sessionId, backendApi, effectiveServiceIdentifier, effectiveServiceVersion, effectiveMapUuid, effectiveGitSha, effectiveLibrary, metadataWithAppUrl);
|
|
868
635
|
if (sessionResponse.data?.startRecordingSession) {
|
|
869
636
|
const websocket = await initializeRecording(captureSettings,
|
|
870
637
|
// DEFAULT_NETWORK_CAPTURE_SETTINGS,
|
|
@@ -893,6 +660,7 @@ export const initRecorder = async (options) => {
|
|
|
893
660
|
backendApi: options.backendApi ?? "https://api-service.sailfishqa.com",
|
|
894
661
|
getSessionId: () => getOrSetSessionId(),
|
|
895
662
|
shortcuts: options.reportIssueShortcuts,
|
|
663
|
+
customBaseUrl: options.customBaseUrl,
|
|
896
664
|
});
|
|
897
665
|
});
|
|
898
666
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// ── SSR guards ─────────────────────────────────────────
|
|
2
|
+
export const HAS_WINDOW = typeof globalThis !== "undefined" &&
|
|
3
|
+
typeof globalThis.window !== "undefined";
|
|
4
|
+
export const HAS_DOCUMENT = typeof globalThis !== "undefined" &&
|
|
5
|
+
typeof globalThis.document !== "undefined";
|
|
6
|
+
export const HAS_LOCAL_STORAGE = typeof globalThis !== "undefined" && "localStorage" in globalThis;
|
|
7
|
+
export const HAS_SESSION_STORAGE = typeof globalThis !== "undefined" && "sessionStorage" in globalThis;
|