@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.
- package/README.md +19 -1
- package/dist/chunkSerializer.js +70 -23
- package/dist/chunkSerializer.js.br +0 -0
- package/dist/chunkSerializer.js.gz +0 -0
- package/dist/chunks/chunkSerializer-DDukZpgl.js +116 -0
- package/dist/chunks/chunkSerializer-DDukZpgl.js.br +0 -0
- package/dist/chunks/chunkSerializer-DDukZpgl.js.gz +0 -0
- package/dist/chunks/chunkSerializer-FQtY90Av.js +115 -0
- package/dist/chunks/chunkSerializer-FQtY90Av.js.br +0 -0
- package/dist/chunks/chunkSerializer-FQtY90Av.js.gz +0 -0
- package/dist/chunks/{index-DiGs9it7.js → index-C-qbsfKe.js} +724 -548
- package/dist/chunks/index-C-qbsfKe.js.br +0 -0
- package/dist/chunks/index-C-qbsfKe.js.gz +0 -0
- package/dist/chunks/{index-CIK1iDN9.js → index-D6axlCRu.js} +757 -577
- package/dist/chunks/index-D6axlCRu.js.br +0 -0
- package/dist/chunks/index-D6axlCRu.js.gz +0 -0
- package/dist/clockSync.js +196 -0
- package/dist/clockSync.js.br +0 -0
- package/dist/clockSync.js.gz +0 -0
- package/dist/errorInterceptor.js +42 -4
- package/dist/errorInterceptor.js.br +0 -0
- package/dist/errorInterceptor.js.gz +0 -0
- package/dist/graphql.js +5 -0
- package/dist/graphql.js.br +0 -0
- package/dist/graphql.js.gz +0 -0
- package/dist/inAppReportIssueModal/index.js +4 -1
- package/dist/inAppReportIssueModal/index.js.br +0 -0
- package/dist/inAppReportIssueModal/index.js.gz +0 -0
- package/dist/inAppReportIssueModal/integrations.js +36 -0
- package/dist/inAppReportIssueModal/integrations.js.br +0 -0
- package/dist/inAppReportIssueModal/integrations.js.gz +0 -0
- package/dist/inAppReportIssueModal/state.js +8 -0
- package/dist/inAppReportIssueModal/state.js.br +0 -0
- package/dist/inAppReportIssueModal/state.js.gz +0 -0
- package/dist/index.js +67 -5
- package/dist/index.js.br +0 -0
- package/dist/index.js.gz +0 -0
- package/dist/privacyMask.js +93 -0
- package/dist/privacyMask.js.br +0 -0
- package/dist/privacyMask.js.gz +0 -0
- package/dist/recorder.cjs +2 -2
- package/dist/recorder.cjs.br +0 -0
- package/dist/recorder.cjs.gz +0 -0
- package/dist/recorder.js +17 -14
- package/dist/recorder.js.br +0 -0
- package/dist/recorder.js.gz +0 -0
- package/dist/recorder.umd.cjs +1338 -1140
- package/dist/recorder.umd.cjs.br +0 -0
- package/dist/recorder.umd.cjs.gz +0 -0
- package/dist/recording.js +84 -13
- package/dist/recording.js.br +0 -0
- package/dist/recording.js.gz +0 -0
- package/dist/types/chunkSerializer.d.ts +14 -0
- package/dist/types/clockSync.d.ts +70 -0
- package/dist/types/inAppReportIssueModal/integrations.d.ts +1 -0
- package/dist/types/inAppReportIssueModal/state.d.ts +2 -0
- package/dist/types/index.d.ts +16 -2
- package/dist/types/privacyMask.d.ts +46 -0
- package/dist/types/recording.d.ts +1 -0
- package/dist/types/types.d.ts +23 -0
- package/dist/types/websocket.d.ts +1 -0
- package/dist/websocket.js +111 -0
- package/dist/websocket.js.br +0 -0
- package/dist/websocket.js.gz +0 -0
- package/package.json +1 -1
- package/dist/chunks/chunkSerializer-C8qtomKe.js +0 -95
- package/dist/chunks/chunkSerializer-C8qtomKe.js.br +0 -0
- package/dist/chunks/chunkSerializer-C8qtomKe.js.gz +0 -0
- package/dist/chunks/chunkSerializer-RWnu-UfC.js +0 -94
- package/dist/chunks/chunkSerializer-RWnu-UfC.js.br +0 -0
- package/dist/chunks/chunkSerializer-RWnu-UfC.js.gz +0 -0
- package/dist/chunks/index-CIK1iDN9.js.br +0 -0
- package/dist/chunks/index-CIK1iDN9.js.gz +0 -0
- package/dist/chunks/index-DiGs9it7.js.br +0 -0
- 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
|
package/dist/errorInterceptor.js
CHANGED
|
@@ -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 =
|
|
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
|
|
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
package/dist/graphql.js.br
CHANGED
|
Binary file
|
package/dist/graphql.js.gz
CHANGED
|
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) {
|
|
Binary file
|
|
Binary file
|
|
@@ -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");
|
|
Binary file
|
|
Binary file
|
|
@@ -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;
|
|
Binary file
|
|
Binary file
|
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
|