@sailfish-ai/recorder 1.11.5 → 1.12.4

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.
Files changed (75) hide show
  1. package/README.md +19 -1
  2. package/dist/chunkSerializer.js +70 -23
  3. package/dist/chunkSerializer.js.br +0 -0
  4. package/dist/chunkSerializer.js.gz +0 -0
  5. package/dist/chunks/chunkSerializer-DDukZpgl.js +116 -0
  6. package/dist/chunks/chunkSerializer-DDukZpgl.js.br +0 -0
  7. package/dist/chunks/chunkSerializer-DDukZpgl.js.gz +0 -0
  8. package/dist/chunks/chunkSerializer-FQtY90Av.js +115 -0
  9. package/dist/chunks/chunkSerializer-FQtY90Av.js.br +0 -0
  10. package/dist/chunks/chunkSerializer-FQtY90Av.js.gz +0 -0
  11. package/dist/chunks/{index-DiGs9it7.js → index-C-qbsfKe.js} +724 -548
  12. package/dist/chunks/index-C-qbsfKe.js.br +0 -0
  13. package/dist/chunks/index-C-qbsfKe.js.gz +0 -0
  14. package/dist/chunks/{index-CIK1iDN9.js → index-D6axlCRu.js} +757 -577
  15. package/dist/chunks/index-D6axlCRu.js.br +0 -0
  16. package/dist/chunks/index-D6axlCRu.js.gz +0 -0
  17. package/dist/clockSync.js +196 -0
  18. package/dist/clockSync.js.br +0 -0
  19. package/dist/clockSync.js.gz +0 -0
  20. package/dist/errorInterceptor.js +42 -4
  21. package/dist/errorInterceptor.js.br +0 -0
  22. package/dist/errorInterceptor.js.gz +0 -0
  23. package/dist/graphql.js +5 -0
  24. package/dist/graphql.js.br +0 -0
  25. package/dist/graphql.js.gz +0 -0
  26. package/dist/inAppReportIssueModal/index.js +4 -1
  27. package/dist/inAppReportIssueModal/index.js.br +0 -0
  28. package/dist/inAppReportIssueModal/index.js.gz +0 -0
  29. package/dist/inAppReportIssueModal/integrations.js +36 -0
  30. package/dist/inAppReportIssueModal/integrations.js.br +0 -0
  31. package/dist/inAppReportIssueModal/integrations.js.gz +0 -0
  32. package/dist/inAppReportIssueModal/state.js +8 -0
  33. package/dist/inAppReportIssueModal/state.js.br +0 -0
  34. package/dist/inAppReportIssueModal/state.js.gz +0 -0
  35. package/dist/index.js +67 -5
  36. package/dist/index.js.br +0 -0
  37. package/dist/index.js.gz +0 -0
  38. package/dist/privacyMask.js +93 -0
  39. package/dist/privacyMask.js.br +0 -0
  40. package/dist/privacyMask.js.gz +0 -0
  41. package/dist/recorder.cjs +2 -2
  42. package/dist/recorder.cjs.br +0 -0
  43. package/dist/recorder.cjs.gz +0 -0
  44. package/dist/recorder.js +17 -14
  45. package/dist/recorder.js.br +0 -0
  46. package/dist/recorder.js.gz +0 -0
  47. package/dist/recorder.umd.cjs +1338 -1140
  48. package/dist/recorder.umd.cjs.br +0 -0
  49. package/dist/recorder.umd.cjs.gz +0 -0
  50. package/dist/recording.js +84 -13
  51. package/dist/recording.js.br +0 -0
  52. package/dist/recording.js.gz +0 -0
  53. package/dist/types/chunkSerializer.d.ts +14 -0
  54. package/dist/types/clockSync.d.ts +70 -0
  55. package/dist/types/inAppReportIssueModal/integrations.d.ts +1 -0
  56. package/dist/types/inAppReportIssueModal/state.d.ts +2 -0
  57. package/dist/types/index.d.ts +16 -2
  58. package/dist/types/privacyMask.d.ts +46 -0
  59. package/dist/types/recording.d.ts +1 -0
  60. package/dist/types/types.d.ts +23 -0
  61. package/dist/types/websocket.d.ts +1 -0
  62. package/dist/websocket.js +111 -0
  63. package/dist/websocket.js.br +0 -0
  64. package/dist/websocket.js.gz +0 -0
  65. package/package.json +1 -1
  66. package/dist/chunks/chunkSerializer-C8qtomKe.js +0 -95
  67. package/dist/chunks/chunkSerializer-C8qtomKe.js.br +0 -0
  68. package/dist/chunks/chunkSerializer-C8qtomKe.js.gz +0 -0
  69. package/dist/chunks/chunkSerializer-RWnu-UfC.js +0 -94
  70. package/dist/chunks/chunkSerializer-RWnu-UfC.js.br +0 -0
  71. package/dist/chunks/chunkSerializer-RWnu-UfC.js.gz +0 -0
  72. package/dist/chunks/index-CIK1iDN9.js.br +0 -0
  73. package/dist/chunks/index-CIK1iDN9.js.gz +0 -0
  74. package/dist/chunks/index-DiGs9it7.js.br +0 -0
  75. package/dist/chunks/index-DiGs9it7.js.gz +0 -0
Binary file
Binary file
@@ -0,0 +1,196 @@
1
+ // Browser → server clock synchronization.
2
+ //
3
+ // Browser wall time (Date.now()) is untrustworthy: users can have wrong system
4
+ // clocks, NTP can jump them, sleep/resume can drift them, and they can be
5
+ // spoofed. We need every event to carry a server-comparable timestamp.
6
+ //
7
+ // Strategy: NTP-style handshake over the existing WebSocket. The browser
8
+ // records performance.now() before and after each handshake; the server
9
+ // returns its receive/send wall times. From those we compute RTT and a
10
+ // midpoint-aligned estimate of (serverWall - browserMonotonic), which we then
11
+ // add to performance.now() at any later moment to estimate server wall time.
12
+ //
13
+ // performance.now() is preferred over Date.now() because it is monotonic and
14
+ // unaffected by user clock changes mid-session.
15
+ const HAS_WINDOW = typeof window !== "undefined";
16
+ const HAS_PERFORMANCE = typeof performance !== "undefined" && typeof performance.now === "function";
17
+ // Caps how many samples we retain. We only ever rank-select a few; the rest
18
+ // are pruned on age so we don't grow unbounded across a long session.
19
+ const MAX_SAMPLES = 20;
20
+ const MAX_SAMPLE_AGE_MS = 10 * 60 * 1000; // 10 minutes
21
+ // Filter cutoff: samples with RTT above this are not considered usable.
22
+ const MAX_USABLE_RTT_MS = 500;
23
+ // Number of best (lowest-RTT) samples we take the median over.
24
+ const BEST_SAMPLE_COUNT = 5;
25
+ // Browser monotonic clock. Falls back to Date.now() when performance is
26
+ // unavailable (SSR / very old runtimes).
27
+ export function monotonicNow() {
28
+ return HAS_PERFORMANCE ? performance.now() : Date.now();
29
+ }
30
+ export function timeOriginMs() {
31
+ return HAS_PERFORMANCE ? performance.timeOrigin : 0;
32
+ }
33
+ class ClockSyncManager {
34
+ samples = [];
35
+ pending = new Map();
36
+ estimatedOffsetMs = null;
37
+ bestRttMs = null;
38
+ lastSyncMonoMs = null;
39
+ // Begins a sync exchange. The caller is responsible for actually sending
40
+ // the request payload — we just record what we need to correlate the reply.
41
+ beginSync(requestId) {
42
+ const entry = {
43
+ requestId,
44
+ clientSendMonoMs: monotonicNow(),
45
+ };
46
+ this.pending.set(requestId, entry);
47
+ return entry;
48
+ }
49
+ // Drops a pending request without recording a sample. Use when a request
50
+ // times out or the websocket closes while it was in flight.
51
+ abandonSync(requestId) {
52
+ this.pending.delete(requestId);
53
+ }
54
+ // Process a server reply. Returns the sample we recorded, or null if the
55
+ // requestId was unknown (late reply after abandon, duplicate, etc.).
56
+ recordTimeSyncResponse(body) {
57
+ const pending = this.pending.get(body.requestId);
58
+ if (!pending)
59
+ return null;
60
+ this.pending.delete(body.requestId);
61
+ const clientReceiveMonoMs = monotonicNow();
62
+ const rttMs = clientReceiveMonoMs - pending.clientSendMonoMs;
63
+ if (rttMs < 0 || !isFinite(rttMs))
64
+ return null;
65
+ const clientMidpointMono = (pending.clientSendMonoMs + clientReceiveMonoMs) / 2;
66
+ const serverMidpointWall = (body.serverReceivedAtMs + body.serverSentAtMs) / 2;
67
+ const estimatedOffsetMs = serverMidpointWall - clientMidpointMono;
68
+ const sample = {
69
+ clientSendMonoMs: pending.clientSendMonoMs,
70
+ clientReceiveMonoMs,
71
+ serverReceiveWallMs: body.serverReceivedAtMs,
72
+ serverSendWallMs: body.serverSentAtMs,
73
+ rttMs,
74
+ estimatedOffsetMs,
75
+ browserWallAtSyncMs: Date.now(),
76
+ };
77
+ this.samples.push(sample);
78
+ this.pruneSamples(clientReceiveMonoMs);
79
+ this.recompute(clientReceiveMonoMs);
80
+ return sample;
81
+ }
82
+ // Returns null when no estimate is available yet.
83
+ estimateServerTime(monoMs) {
84
+ if (this.estimatedOffsetMs == null)
85
+ return null;
86
+ return this.estimatedOffsetMs + monoMs;
87
+ }
88
+ getSyncMetadata() {
89
+ const now = monotonicNow();
90
+ const syncAgeMs = this.lastSyncMonoMs != null ? now - this.lastSyncMonoMs : null;
91
+ return {
92
+ offsetMs: this.estimatedOffsetMs,
93
+ rttMs: this.bestRttMs,
94
+ syncAgeMs,
95
+ };
96
+ }
97
+ // Difference between browser wall clock and estimated server wall clock at
98
+ // the time of the most recent sample. Positive means browser clock is ahead.
99
+ getBrowserClockSkewMs() {
100
+ if (this.samples.length === 0)
101
+ return null;
102
+ const latest = this.samples[this.samples.length - 1];
103
+ const serverWallAtLatest = latest.estimatedOffsetMs + latest.clientReceiveMonoMs;
104
+ return latest.browserWallAtSyncMs - serverWallAtLatest;
105
+ }
106
+ reset() {
107
+ this.samples = [];
108
+ this.pending.clear();
109
+ this.estimatedOffsetMs = null;
110
+ this.bestRttMs = null;
111
+ this.lastSyncMonoMs = null;
112
+ }
113
+ pruneSamples(nowMono) {
114
+ if (this.samples.length === 0)
115
+ return;
116
+ const cutoff = nowMono - MAX_SAMPLE_AGE_MS;
117
+ this.samples = this.samples.filter((s) => s.clientReceiveMonoMs >= cutoff);
118
+ if (this.samples.length > MAX_SAMPLES) {
119
+ this.samples = this.samples.slice(this.samples.length - MAX_SAMPLES);
120
+ }
121
+ }
122
+ recompute(nowMono) {
123
+ const usable = this.samples
124
+ .filter((s) => s.rttMs <= MAX_USABLE_RTT_MS)
125
+ .sort((a, b) => a.rttMs - b.rttMs)
126
+ .slice(0, BEST_SAMPLE_COUNT);
127
+ if (usable.length === 0) {
128
+ // No usable samples; keep last known offset but mark no-recent-sync.
129
+ // (We still update lastSyncMonoMs so the caller can see we tried.)
130
+ this.lastSyncMonoMs = nowMono;
131
+ this.bestRttMs = null;
132
+ return;
133
+ }
134
+ const offsets = usable
135
+ .map((s) => s.estimatedOffsetMs)
136
+ .sort((a, b) => a - b);
137
+ this.estimatedOffsetMs = offsets[Math.floor(offsets.length / 2)];
138
+ this.bestRttMs = usable[0].rttMs;
139
+ this.lastSyncMonoMs = nowMono;
140
+ }
141
+ }
142
+ // Singleton — every consumer in the SDK shares one offset state. Created
143
+ // lazily so SSR imports don't need any browser globals.
144
+ let _instance = null;
145
+ export function getClockSyncManager() {
146
+ if (!_instance) {
147
+ _instance = new ClockSyncManager();
148
+ }
149
+ return _instance;
150
+ }
151
+ // Convenience wrappers for callers that don't care about the instance.
152
+ export function estimateServerTime(monoMs) {
153
+ return getClockSyncManager().estimateServerTime(monoMs);
154
+ }
155
+ export function getClockSyncMetadata() {
156
+ return getClockSyncManager().getSyncMetadata();
157
+ }
158
+ // Builds the `client` and `serverEstimated` envelope attached to every event.
159
+ // `capturedMonoMs` should be performance.now() captured at the moment the
160
+ // event occurred (not when it was eventually flushed).
161
+ export function buildEventTimeEnvelope(capturedMonoMs, capturedWallMs) {
162
+ const mgr = getClockSyncManager();
163
+ const meta = mgr.getSyncMetadata();
164
+ const eventTimeMs = mgr.estimateServerTime(capturedMonoMs);
165
+ return {
166
+ client: {
167
+ wallTimeMs: capturedWallMs,
168
+ monotonicMs: capturedMonoMs,
169
+ timeOriginMs: timeOriginMs(),
170
+ },
171
+ serverEstimated: {
172
+ eventTimeMs,
173
+ offsetMs: meta.offsetMs,
174
+ rttMs: meta.rttMs,
175
+ syncAgeMs: meta.syncAgeMs,
176
+ },
177
+ };
178
+ }
179
+ // Capture-at-source helper for callers that want to stamp the moment the
180
+ // event happened, not the moment sendEvent runs (e.g. after a network body
181
+ // has finished reading). The enrichment step will reuse these values.
182
+ export function captureClientTime() {
183
+ return {
184
+ wallTimeMs: Date.now(),
185
+ monotonicMs: monotonicNow(),
186
+ timeOriginMs: timeOriginMs(),
187
+ };
188
+ }
189
+ // Exported for tests only.
190
+ export const __testing = {
191
+ reset: () => getClockSyncManager().reset(),
192
+ MAX_USABLE_RTT_MS,
193
+ BEST_SAMPLE_COUNT,
194
+ HAS_WINDOW,
195
+ HAS_PERFORMANCE,
196
+ };
Binary file
Binary file
@@ -93,23 +93,56 @@ export async function resolveStackTrace(stackTrace) {
93
93
  }
94
94
  return out;
95
95
  }
96
+ function describeNonError(value) {
97
+ if (value === null)
98
+ return "null";
99
+ if (value === undefined)
100
+ return "undefined";
101
+ if (typeof value === "string")
102
+ return value;
103
+ if (typeof value === "number" ||
104
+ typeof value === "boolean" ||
105
+ typeof value === "bigint") {
106
+ return String(value);
107
+ }
108
+ if (value instanceof Event) {
109
+ const target = value.target;
110
+ const tag = target?.tagName ? `<${target.tagName.toLowerCase()}>` : "";
111
+ return `[${value.type} Event${tag ? ` on ${tag}` : ""}${value.isTrusted ? " trusted" : ""}]`;
112
+ }
113
+ try {
114
+ return JSON.stringify(value);
115
+ }
116
+ catch {
117
+ return Object.prototype.toString.call(value);
118
+ }
119
+ }
96
120
  /**
97
121
  * Captures full error details and resolves the stack trace.
98
122
  */
99
- async function captureError(error, isPromiseRejection = false) {
123
+ async function captureError(error, isPromiseRejection = false, location) {
100
124
  let errorMessage;
101
125
  let stack;
126
+ const payload = [];
102
127
  if (error instanceof Error) {
103
- errorMessage = error.message;
128
+ errorMessage = error.message || error.name;
104
129
  stack = error.stack || "No stack trace";
130
+ payload.push(`${error.name}: ${errorMessage}`);
105
131
  }
106
132
  else if (typeof error === "string") {
107
133
  errorMessage = error;
108
134
  stack = "No stack trace available";
135
+ payload.push(isPromiseRejection ? `Uncaught (in promise) ${error}` : error);
109
136
  }
110
137
  else {
111
- errorMessage = "Unknown error occurred";
138
+ errorMessage = describeNonError(error);
112
139
  stack = "No stack trace available";
140
+ payload.push(isPromiseRejection
141
+ ? `Uncaught (in promise) ${errorMessage}`
142
+ : errorMessage);
143
+ }
144
+ if (location?.filename) {
145
+ payload.push(`at ${location.filename}:${location.lineno ?? 0}:${location.colno ?? 0}`);
113
146
  }
114
147
  const mappedStack = await resolveStackTrace(stack);
115
148
  const filteredStack = mappedStack.filter((line) => !line.includes("chunk-") && !line.includes("react-dom"));
@@ -120,6 +153,7 @@ async function captureError(error, isPromiseRejection = false) {
120
153
  stack,
121
154
  trace,
122
155
  filteredStack,
156
+ payload,
123
157
  userAgent: navigator.userAgent,
124
158
  url: window.location.href,
125
159
  timestamp,
@@ -142,7 +176,11 @@ async function captureError(error, isPromiseRejection = false) {
142
176
  */
143
177
  export function initializeErrorInterceptor() {
144
178
  window.addEventListener("error", (event) => {
145
- captureError(event.error || event.message);
179
+ captureError(event.error ?? event.message, false, {
180
+ filename: event.filename,
181
+ lineno: event.lineno,
182
+ colno: event.colno,
183
+ });
146
184
  });
147
185
  window.addEventListener("unhandledrejection", (event) => {
148
186
  captureError(event.reason, true);
Binary file
Binary file
package/dist/graphql.js CHANGED
@@ -39,6 +39,11 @@ export function fetchCaptureSettings(apiKey, backendApi) {
39
39
  recordDob
40
40
  sampling
41
41
  textEditThrottleEnabled
42
+ maskInputSelector
43
+ maskTextSelector
44
+ blockSelector
45
+ unmaskSelector
46
+ maskInputOptions
42
47
  }
43
48
  }
44
49
  `, { apiKey, backendApi });
Binary file
Binary file
@@ -1,6 +1,6 @@
1
1
  import { createTriageAndIssueFromRecorder, createTriageFromRecorder, } from "../graphql";
2
2
  import { getDefaultReporterAccountId, getFieldsForProject, getIntegrationData, getProjectsForTeam, getSprintFieldId, getUsers, getValidSavedReporterAccountId, hasValidIntegration, refreshIntegrationData, saveLastReporterAccountId, updateFormWithIntegrationData, updateIssueTypeOptions, } from "./integrations";
3
- import { currentState, isRecording, recordingEndTime, recordingStartTime, resetState, setIsRecording, setRecordingEndTime, setRecordingStartTime, setTimerInterval, timerInterval, } from "./state";
3
+ import { currentState, isRecording, recordingEndTime, recordingStartTime, resetState, setIsRecording, setRecordingEndTime, setRecordingStartTime, setSprintDefaulted, setTimerInterval, timerInterval, } from "./state";
4
4
  import { STORAGE_KEYS } from "./types";
5
5
  import { getChevronSVG, renderCustomMultiSelect, renderDynamicField, } from "./ui";
6
6
  // TODO - enable configuration by keyboard typing in UI
@@ -980,6 +980,9 @@ function bindEngTicketListeners() {
980
980
  engSprintSelect.addEventListener("change", () => {
981
981
  currentState.engTicketSprint = engSprintSelect.value;
982
982
  engSprintSelect.style.color = engSprintSelect.value ? "" : "#9ca3af";
983
+ // User made an explicit choice (including clearing) — don't let a
984
+ // later integration data refetch override it with the auto-default.
985
+ setSprintDefaulted(true);
983
986
  });
984
987
  }
985
988
  if (engPrioritySelect) {
@@ -1,5 +1,6 @@
1
1
  import { fetchEngineeringTicketPlatformIntegrations } from "../graphql";
2
2
  import { getIdentifiedUser } from "../sendSailfishMessages";
3
+ import { sprintDefaulted, setSprintDefaulted } from "./state";
3
4
  const SUPPORT_TICKET_INTEGRATIONS = ["jira", "linear", "zendesk"];
4
5
  let integrationData = null;
5
6
  // Cache per cloud so switching between clouds is instant
@@ -124,6 +125,27 @@ export function populatePriorityOptions(selectElement, provider, defaultPriority
124
125
  selectElement.value = "0";
125
126
  }
126
127
  }
128
+ // Mirrors useEngineeringTicketForm.ts:_projectBoardIds — when the chosen
129
+ // project exposes its boards, prefer an active sprint whose originBoardId
130
+ // matches one of them so multi-board Jira projects don't grab a sprint
131
+ // from the wrong board.
132
+ function _projectBoardIds(projectId, projects) {
133
+ if (!projectId)
134
+ return [];
135
+ const project = (projects || []).find((p) => p.id === projectId || p.key === projectId);
136
+ const boards = project?.boards || project?.board_ids || [];
137
+ return boards.map((b) => (typeof b === "object" ? b.id : b));
138
+ }
139
+ export function pickDefaultActiveSprint(sprints, projectId, projects) {
140
+ const activeSprints = (sprints || []).filter((s) => s.state === "active");
141
+ if (!activeSprints.length)
142
+ return null;
143
+ const projectBoardIds = _projectBoardIds(projectId, projects);
144
+ const projectScoped = projectBoardIds.length
145
+ ? activeSprints.find((s) => projectBoardIds.some((bid) => String(bid) === String(s.originBoardId)))
146
+ : null;
147
+ return projectScoped || activeSprints[0];
148
+ }
127
149
  export function populateSprintOptions(selectElement, sprints, currentValue) {
128
150
  selectElement.innerHTML = "";
129
151
  // Placeholder option
@@ -407,7 +429,21 @@ export function updateFormWithIntegrationData(currentState) {
407
429
  const sprintSelect = document.getElementById("sf-eng-ticket-sprint");
408
430
  const isJira = integrationData.provider?.toLowerCase() === "jira";
409
431
  if (sprintSelect && isJira && integrationData.sprints) {
432
+ // Default to the current active sprint on first population so Report
433
+ // Issue-created Jira tickets land in the current sprint, matching the
434
+ // dashboard EngineeringTicketModal's auto-default behavior
435
+ // (useEngineeringTicketForm.ts:956-991).
436
+ if (!currentState.engTicketSprint && !sprintDefaulted) {
437
+ const chosen = pickDefaultActiveSprint(integrationData.sprints, currentState.engTicketProject, integrationData.projects || []);
438
+ if (chosen?.id) {
439
+ currentState.engTicketSprint = String(chosen.id);
440
+ setSprintDefaulted(true);
441
+ }
442
+ }
410
443
  populateSprintOptions(sprintSelect, integrationData.sprints, currentState.engTicketSprint || undefined);
444
+ if (currentState.engTicketSprint) {
445
+ sprintSelect.style.color = "";
446
+ }
411
447
  }
412
448
  // Update issue type dropdown based on selected project (for Jira)
413
449
  const issueTypeSelect = document.getElementById("sf-eng-ticket-type");
@@ -35,6 +35,10 @@ export let recordingStartTime = null;
35
35
  export let recordingEndTime = null;
36
36
  export let timerInterval = null;
37
37
  export let isRecording = false;
38
+ // Tracks whether the Jira sprint dropdown has already been auto-defaulted
39
+ // for this modal session, so refetches of integration data don't override
40
+ // a user-cleared/changed value.
41
+ export let sprintDefaulted = false;
38
42
  // State setters
39
43
  export function setCurrentState(state) {
40
44
  currentState = state;
@@ -43,6 +47,10 @@ export function resetState() {
43
47
  currentState = getInitialState();
44
48
  recordingStartTime = null;
45
49
  recordingEndTime = null;
50
+ sprintDefaulted = false;
51
+ }
52
+ export function setSprintDefaulted(value) {
53
+ sprintDefaulted = value;
46
54
  }
47
55
  export function setRecordingStartTime(time) {
48
56
  recordingStartTime = time;
package/dist/index.js CHANGED
@@ -11,10 +11,11 @@ import { sendMapUuidIfAvailable } from "./mapUuid";
11
11
  import { getUrlAndStoredUuids, initializeConsolePlugin, initializeDomContentEvents, initializePerformancePlugin, initializeRecording, invalidateUrlCache, } from "./recording";
12
12
  import { HAS_DOCUMENT, HAS_LOCAL_STORAGE, HAS_SESSION_STORAGE, HAS_WINDOW, } from "./runtimeEnv";
13
13
  import { isHeadlessOrLighthouse } from "./headlessDetection";
14
+ import { captureClientTime } from "./clockSync";
14
15
  import { ensureSessionListeners, getOrSetSessionId } from "./session";
15
16
  import { withAppUrlMetadata } from "./utils";
16
17
  import { onNavigationChange } from "./websocket";
17
- import { clearStaleFuncSpanState, getFuncSpanHeader, isFunctionSpanTrackingEnabled, restoreFuncSpanState, sendEvent, sendMessage, } from "./websocket";
18
+ import { clearStaleFuncSpanState, getFuncSpanHeader, isFunctionSpanTrackingEnabled, requestTimeSync, restoreFuncSpanState, sendEvent, sendMessage, } from "./websocket";
18
19
  import { yieldToMain } from "./scheduler";
19
20
  import { detectFramework } from "./frameworkDetection";
20
21
  const DEBUG = readDebugFlag(); // A wrapper around fetch that suppresses connection refused errors
@@ -73,6 +74,13 @@ export const DEFAULT_CAPTURE_SETTINGS = {
73
74
  recordDob: false,
74
75
  sampling: {},
75
76
  enableFiberTracking: false,
77
+ // Form Privacy Rules — empty = no rule. Mirrors the backend defaults so the
78
+ // recorder behaves identically whether the server payload arrives or not.
79
+ maskInputSelector: "",
80
+ maskTextSelector: "",
81
+ blockSelector: "",
82
+ unmaskSelector: "",
83
+ maskInputOptions: { password: true },
76
84
  };
77
85
  export const DEFAULT_CONSOLE_RECORDING_SETTINGS = {
78
86
  level: ["info", "log", "warn", "error"],
@@ -154,6 +162,22 @@ function trackDomainChangesOnce() {
154
162
  sessionStorage.setItem("prevPageVisitUUID", prevPageVisitUUID);
155
163
  invalidateUrlCache();
156
164
  const timestamp = Date.now();
165
+ // Opt-in diagnostic log for the duplicate-/-skipped page_visit_uuid bug.
166
+ // Gated by enableInternalDebugLogs (passed via initRecorder); off by
167
+ // default so customer integrations see no console noise. Captures the
168
+ // call stack so we can tell which trigger fired (pushState, replaceState,
169
+ // popstate, the 1s poll, or the initial forced mint).
170
+ if (g.internalDebugLogs) {
171
+ // eslint-disable-next-line no-console
172
+ console.log("[sf-recorder] mint pageVisitUUID", {
173
+ url: currentDomain,
174
+ newUuid: pageVisitUUID,
175
+ prevUuid: prevPageVisitUUID,
176
+ forceSend,
177
+ timestamp,
178
+ stack: new Error().stack,
179
+ });
180
+ }
157
181
  sendMessage({
158
182
  type: "routeChange",
159
183
  data: {
@@ -226,6 +250,9 @@ function handleVisibilityChange() {
226
250
  // Restore sessionId when the user returns to the page
227
251
  if (visibilityState === "visible") {
228
252
  getOrSetSessionId();
253
+ // Background tabs commonly suffer clock drift (sleep/resume); refresh the
254
+ // browser→server offset before any post-resume events fire.
255
+ requestTimeSync();
229
256
  }
230
257
  // Send visibility change event for both visible and hidden states
231
258
  try {
@@ -285,6 +312,11 @@ function _ensureModuleSideEffects() {
285
312
  window.addEventListener("beforeunload", () => {
286
313
  clearPageVisitDataFromSessionStorage();
287
314
  });
315
+ // Reconnection is a strong trigger for stale clock offset (DHCP-driven
316
+ // NTP correction often happens here).
317
+ window.addEventListener("online", () => {
318
+ requestTimeSync();
319
+ });
288
320
  }
289
321
  }
290
322
  function storeCredentialsAndConnection({ apiKey, backendApi, }) {
@@ -354,6 +386,23 @@ function matchParsedUrlAgainstPatterns(parsed, patterns) {
354
386
  return true;
355
387
  });
356
388
  }
389
+ // Resolve a captured request URL to absolute form before it is transmitted.
390
+ // Idempotent: already-absolute URLs (https:, blob:, chrome-extension:) pass
391
+ // through unchanged; relative/protocol-relative URLs gain the page origin.
392
+ // SSR-safe: never touches `window` unguarded (see veritas/jsts-frontend/CLAUDE.md).
393
+ export function toAbsoluteUrl(url) {
394
+ if (typeof url !== "string" || url.length === 0)
395
+ return url;
396
+ try {
397
+ const base = typeof window !== "undefined"
398
+ ? window.location.href
399
+ : "http://localhost/";
400
+ return new URL(url, base).href;
401
+ }
402
+ catch {
403
+ return url;
404
+ }
405
+ }
357
406
  // Public wrapper: parse + delegate. Preserved for callers outside the gate
358
407
  // (interceptor wrappers, tests). Hot paths should call
359
408
  // matchParsedUrlAgainstPatterns() directly with a pre-parsed URL.
@@ -515,7 +564,7 @@ function setupXMLHttpRequestInterceptor(domainsToNotPropagateHeaderTo = [], body
515
564
  success,
516
565
  error: errorMsg,
517
566
  method: this._requestMethod,
518
- url,
567
+ url: toAbsoluteUrl(url),
519
568
  request_headers: requestHeaders,
520
569
  request_body: requestBody,
521
570
  response_headers: responseHeaders,
@@ -790,6 +839,9 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
790
839
  isRetry = true;
791
840
  }
792
841
  const endTime = Date.now();
842
+ // Stamp client.* at the moment the response arrived; body capture may
843
+ // delay sendEvent() by hundreds of ms for streaming/large bodies.
844
+ const requestCompletionTime = captureClientTime();
793
845
  const status = response.status;
794
846
  const success = response.ok;
795
847
  const error = success ? "" : `Request Error: ${response.statusText}`;
@@ -812,6 +864,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
812
864
  type: NetworkRequestEventId,
813
865
  timestamp: endTime,
814
866
  sessionId,
867
+ client: requestCompletionTime,
815
868
  data: {
816
869
  request_id: networkUUID,
817
870
  session_id: sessionId,
@@ -821,7 +874,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
821
874
  success,
822
875
  error,
823
876
  method,
824
- url,
877
+ url: toAbsoluteUrl(url),
825
878
  retry_without_trace_id: isRetry,
826
879
  request_headers: requestHeaders,
827
880
  request_body: requestBody,
@@ -930,7 +983,7 @@ function setupFetchInterceptor(domainsToNotPropagateHeadersTo = [], bodyCaptureC
930
983
  success,
931
984
  error: errorMessage,
932
985
  method,
933
- url,
986
+ url: toAbsoluteUrl(url),
934
987
  request_headers: requestHeaders,
935
988
  request_body: resolvedRequestBody,
936
989
  response_body: null,
@@ -1040,7 +1093,7 @@ export async function startRecording({ apiKey, backendApi = "https://api-service
1040
1093
  // Pass [] to fully disable header propagation. Pass exact patterns
1041
1094
  // (e.g. ["api.myapp.com", "*.internal.com"]) to restrict propagation
1042
1095
  // to a known set of domains.
1043
- domainsToPropagateHeaderTo = ["*"], domainsToNotPropagateHeaderTo = [], serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody = true, captureResponseBodyMaxMb = 10, captureStreamPrefixKb = 64, captureStreamTimeoutMs = 10000, enableFiberTracking = false, deferRecording, deferRecordingStart, chunkSnapshot, useWsWorker = true, capturePerformanceMetrics = true, maskTextClass, library, headlessRecording = false, }) {
1096
+ domainsToPropagateHeaderTo = ["*"], domainsToNotPropagateHeaderTo = [], serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody = true, captureResponseBodyMaxMb = 10, captureStreamPrefixKb = 64, captureStreamTimeoutMs = 10000, enableFiberTracking = false, deferRecording, deferRecordingStart, chunkSnapshot, useWsWorker = true, capturePerformanceMetrics = true, maskTextClass, maskInputSelector, maskTextSelector, blockSelector, unmaskSelector, maskInputOptions, library, headlessRecording = false, }) {
1044
1097
  // Synthetic-environment no-op: Lighthouse/PSI, HeadlessChrome, WebPageTest
1045
1098
  // (PTST), Puppeteer/Playwright/Selenium (navigator.webdriver). We skip init
1046
1099
  // entirely to avoid WSS retry noise, third-party perf penalties in audits,
@@ -1196,6 +1249,12 @@ domainsToPropagateHeaderTo = ["*"], domainsToNotPropagateHeaderTo = [], serviceV
1196
1249
  // Caller-supplied maskTextClass wins over server-fetched value; only
1197
1250
  // applied when explicitly set so undefined cannot clobber the server.
1198
1251
  ...(maskTextClass !== undefined ? { maskTextClass } : {}),
1252
+ // Form Privacy Rules — same caller-overrides-when-defined pattern.
1253
+ ...(maskInputSelector !== undefined ? { maskInputSelector } : {}),
1254
+ ...(maskTextSelector !== undefined ? { maskTextSelector } : {}),
1255
+ ...(blockSelector !== undefined ? { blockSelector } : {}),
1256
+ ...(unmaskSelector !== undefined ? { unmaskSelector } : {}),
1257
+ ...(maskInputOptions !== undefined ? { maskInputOptions } : {}),
1199
1258
  };
1200
1259
  // If a socket is already open now, stop here.
1201
1260
  if (g.ws && g.ws.readyState === 1) {
@@ -1233,6 +1292,9 @@ export const initRecorder = async (options) => {
1233
1292
  if (typeof window === "undefined")
1234
1293
  return;
1235
1294
  const g = (window.__sailfish_recorder ||= {});
1295
+ // Stash the debug flag on the global so trackDomainChangesOnce and other
1296
+ // helpers can read it without being passed through every call site.
1297
+ g.internalDebugLogs = options.enableInternalDebugLogs === true;
1236
1298
  const currentSessionId = getOrSetSessionId();
1237
1299
  // remove stale page visit data from previous sessions
1238
1300
  clearPageVisitDataFromSessionStorage();
package/dist/index.js.br CHANGED
Binary file
package/dist/index.js.gz CHANGED
Binary file