@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
package/dist/recorder.umd.cjs.br
CHANGED
|
Binary file
|
package/dist/recorder.umd.cjs.gz
CHANGED
|
Binary file
|
package/dist/recording.js
CHANGED
|
@@ -4,6 +4,29 @@ import { Complete, DomContentEventId, DomContentSource, Loading, PerformanceMetr
|
|
|
4
4
|
import { getCallerLocation, getCallerLocationFromTrace, } from "./sourceLocation";
|
|
5
5
|
import suppressConsoleLogsDuringCall from "./suppressConsoleLogsDuringCall";
|
|
6
6
|
import { yieldToMain } from "./scheduler";
|
|
7
|
+
import { buildMaskInputFn, makeMaskTextFn } from "./privacyMask";
|
|
8
|
+
// rrweb's full set of input-type / tag keys — used to expand
|
|
9
|
+
// maskInputOptions to all-on when admin set a maskInputSelector, so rrweb
|
|
10
|
+
// routes every input through our wrapper which then enforces precise
|
|
11
|
+
// per-element rules.
|
|
12
|
+
const RRWEB_ALL_INPUT_TYPES = {
|
|
13
|
+
color: true,
|
|
14
|
+
date: true,
|
|
15
|
+
"datetime-local": true,
|
|
16
|
+
email: true,
|
|
17
|
+
month: true,
|
|
18
|
+
number: true,
|
|
19
|
+
range: true,
|
|
20
|
+
search: true,
|
|
21
|
+
tel: true,
|
|
22
|
+
text: true,
|
|
23
|
+
time: true,
|
|
24
|
+
url: true,
|
|
25
|
+
week: true,
|
|
26
|
+
textarea: true,
|
|
27
|
+
select: true,
|
|
28
|
+
password: true,
|
|
29
|
+
};
|
|
7
30
|
import { getCachedHrefNoQuery, initializeWebSocket, sendEvent } from "./websocket";
|
|
8
31
|
// Module-level reference to rrweb record, populated after dynamic import
|
|
9
32
|
let _record = null;
|
|
@@ -58,19 +81,22 @@ function zE_safe(...args) {
|
|
|
58
81
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
59
82
|
// Recorder logic
|
|
60
83
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
-
function maskInputFn(text, node) {
|
|
84
|
+
export function maskInputFn(text, node) {
|
|
62
85
|
if (node.type === "hidden") {
|
|
63
86
|
return "";
|
|
64
87
|
}
|
|
88
|
+
// rrweb only calls this fn when it has already decided to mask this input
|
|
89
|
+
// (maskInputOptions[type] matched, or maskInputSelector matched, or the
|
|
90
|
+
// element is under maskTextClass). When we run, we MUST return a masked
|
|
91
|
+
// value — returning raw text silently defeats rrweb's masking decision and
|
|
92
|
+
// leaks user input to the WS payload. Specific PII formats keep their
|
|
93
|
+
// length-preserving partial masks for player readability; everything else
|
|
94
|
+
// falls back to a full-asterisk mask (matching rrweb's built-in default).
|
|
65
95
|
const patterns = {
|
|
66
96
|
creditCard: /\b(?:\d[ -]*?){13,16}\b/,
|
|
67
97
|
ssn: /\b\d{3}-\d{2}-\d{4}\b/,
|
|
68
98
|
};
|
|
69
|
-
|
|
70
|
-
if (node.closest(`.${MASK_CLASS}`)) {
|
|
71
|
-
return "*".repeat(text.length);
|
|
72
|
-
}
|
|
73
|
-
else if (node.hasAttribute("data-cc") ||
|
|
99
|
+
if (node.hasAttribute("data-cc") ||
|
|
74
100
|
(node.getAttribute("autocomplete")?.startsWith("cc-") ?? false) ||
|
|
75
101
|
patterns.creditCard.test(text)) {
|
|
76
102
|
return "**** **** **** " + text.slice(-4);
|
|
@@ -81,7 +107,7 @@ function maskInputFn(text, node) {
|
|
|
81
107
|
else if (node.hasAttribute("data-dob")) {
|
|
82
108
|
return "**/**/" + text.slice(-4);
|
|
83
109
|
}
|
|
84
|
-
return text;
|
|
110
|
+
return "*".repeat(text.length);
|
|
85
111
|
}
|
|
86
112
|
// Cached sessionStorage values — avoids per-call sessionStorage reads on the hot path
|
|
87
113
|
let _ssCacheDirty = true;
|
|
@@ -325,6 +351,36 @@ backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker
|
|
|
325
351
|
_record = record;
|
|
326
352
|
// Yield before rrweb record() — the heaviest synchronous operation
|
|
327
353
|
await yieldToMain();
|
|
354
|
+
// Effective Form Privacy Rule options. Empty strings from the server
|
|
355
|
+
// collapse to undefined so rrweb / our walker can short-circuit.
|
|
356
|
+
const unmaskSelector = captureSettings.unmaskSelector || undefined;
|
|
357
|
+
const blockSelector = captureSettings.blockSelector || undefined;
|
|
358
|
+
const maskTextSelector = captureSettings.maskTextSelector || undefined;
|
|
359
|
+
const maskInputSelector = captureSettings.maskInputSelector || undefined;
|
|
360
|
+
// ── Admin's intended maskInputOptions ──
|
|
361
|
+
// No floor: when admin's maskInputOptions is empty (every checkbox
|
|
362
|
+
// unticked), no type-based masking applies. unmaskSelector and
|
|
363
|
+
// maskInputSelector still work independently. "Off means off."
|
|
364
|
+
const adminMaskInputOptions = {
|
|
365
|
+
...(captureSettings.maskInputOptions ?? {}),
|
|
366
|
+
};
|
|
367
|
+
// ── rrweb-facing maskInputOptions ──
|
|
368
|
+
// rrweb's input observer only consults maskInputOptions[type]; it has
|
|
369
|
+
// no concept of maskInputSelector. When admin set one, expand to all-on
|
|
370
|
+
// so rrweb calls our wrapper for every input — the wrapper then enforces
|
|
371
|
+
// the precise per-element decision (type match OR selector match,
|
|
372
|
+
// exempted by unmaskSelector). Without a selector, keep the original
|
|
373
|
+
// narrow set so unconfigured types stay raw at rrweb's level.
|
|
374
|
+
const effectiveMaskInputOptions = maskInputSelector
|
|
375
|
+
? { ...RRWEB_ALL_INPUT_TYPES }
|
|
376
|
+
: adminMaskInputOptions;
|
|
377
|
+
const effectiveMaskInputFn = buildMaskInputFn({
|
|
378
|
+
baseFn: maskInputFn,
|
|
379
|
+
unmaskSelector,
|
|
380
|
+
maskInputSelector,
|
|
381
|
+
adminMaskInputOptions,
|
|
382
|
+
});
|
|
383
|
+
const effectiveMaskTextFn = makeMaskTextFn(unmaskSelector);
|
|
328
384
|
if (chunkSnapshot) {
|
|
329
385
|
// ── Chunked snapshot mode ──────────────────────────────────────
|
|
330
386
|
// Instead of rrweb's synchronous full-DOM snapshot (which blocks
|
|
@@ -348,9 +404,15 @@ backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker
|
|
|
348
404
|
emitWithContext(event);
|
|
349
405
|
}
|
|
350
406
|
},
|
|
351
|
-
maskInputOptions: { text: true },
|
|
352
|
-
maskInputFn,
|
|
353
407
|
...captureSettings,
|
|
408
|
+
// Placed AFTER spread so captureSettings keys can't bypass the
|
|
409
|
+
// unmask shim or the {text:true} floor.
|
|
410
|
+
maskInputOptions: effectiveMaskInputOptions,
|
|
411
|
+
maskInputFn: effectiveMaskInputFn,
|
|
412
|
+
maskTextFn: effectiveMaskTextFn,
|
|
413
|
+
maskInputSelector,
|
|
414
|
+
maskTextSelector,
|
|
415
|
+
blockSelector,
|
|
354
416
|
maskTextClass: captureSettings.maskTextClass ?? MASK_CLASS,
|
|
355
417
|
recordDOM: false, // after spread so it can't be overridden
|
|
356
418
|
});
|
|
@@ -359,9 +421,14 @@ backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker
|
|
|
359
421
|
chunkSize: 500,
|
|
360
422
|
maxChunkMs: 16,
|
|
361
423
|
blockClass: captureSettings.blockClass,
|
|
362
|
-
blockSelector
|
|
424
|
+
blockSelector,
|
|
363
425
|
maskTextClass: captureSettings.maskTextClass ?? MASK_CLASS,
|
|
364
|
-
maskTextSelector
|
|
426
|
+
maskTextSelector,
|
|
427
|
+
maskInputSelector,
|
|
428
|
+
maskInputOptions: effectiveMaskInputOptions,
|
|
429
|
+
maskInputFn: effectiveMaskInputFn,
|
|
430
|
+
maskTextFn: effectiveMaskTextFn,
|
|
431
|
+
unmaskSelector,
|
|
365
432
|
});
|
|
366
433
|
if (serializedDoc) {
|
|
367
434
|
// Emit Meta event
|
|
@@ -411,9 +478,13 @@ backendApi, apiKey, sessionId, envValue, deferRecordingStart = true, useWsWorker
|
|
|
411
478
|
emit(event) {
|
|
412
479
|
emitWithContext(event);
|
|
413
480
|
},
|
|
414
|
-
maskInputOptions: { text: true },
|
|
415
|
-
maskInputFn,
|
|
416
481
|
...captureSettings,
|
|
482
|
+
maskInputOptions: effectiveMaskInputOptions,
|
|
483
|
+
maskInputFn: effectiveMaskInputFn,
|
|
484
|
+
maskTextFn: effectiveMaskTextFn,
|
|
485
|
+
maskInputSelector,
|
|
486
|
+
maskTextSelector,
|
|
487
|
+
blockSelector,
|
|
417
488
|
maskTextClass: captureSettings.maskTextClass ?? MASK_CLASS,
|
|
418
489
|
});
|
|
419
490
|
}
|
package/dist/recording.js.br
CHANGED
|
Binary file
|
package/dist/recording.js.gz
CHANGED
|
Binary file
|
|
@@ -6,6 +6,10 @@ export interface ChunkMirror {
|
|
|
6
6
|
hasNode(node: Node): boolean;
|
|
7
7
|
getId(node: Node): number;
|
|
8
8
|
}
|
|
9
|
+
/** Per-input-type mask toggles. Keys match rrweb's MaskInputOptions. */
|
|
10
|
+
export interface ChunkMaskInputOptions {
|
|
11
|
+
[inputType: string]: boolean | undefined;
|
|
12
|
+
}
|
|
9
13
|
export interface ChunkOptions {
|
|
10
14
|
/** Yield after this many nodes (default: 500) */
|
|
11
15
|
chunkSize?: number;
|
|
@@ -19,6 +23,16 @@ export interface ChunkOptions {
|
|
|
19
23
|
maskTextClass?: string;
|
|
20
24
|
/** rrweb maskTextSelector — CSS selector for masked text containers */
|
|
21
25
|
maskTextSelector?: string | null;
|
|
26
|
+
/** CSS selector for inputs whose values should be masked. */
|
|
27
|
+
maskInputSelector?: string | null;
|
|
28
|
+
/** Per-input-type toggles (text, password, email, …). */
|
|
29
|
+
maskInputOptions?: ChunkMaskInputOptions;
|
|
30
|
+
/** Optional override for input-value masking. Receives raw value + node. */
|
|
31
|
+
maskInputFn?: (text: string, element: HTMLElement) => string;
|
|
32
|
+
/** Optional override for text-node masking. Receives raw text + parent. */
|
|
33
|
+
maskTextFn?: (text: string, element: HTMLElement | null) => string;
|
|
34
|
+
/** CSS selector that exempts ancestors from any masking/blocking. */
|
|
35
|
+
unmaskSelector?: string | null;
|
|
22
36
|
}
|
|
23
37
|
/**
|
|
24
38
|
* Async chunked DOM serializer. Walks the document tree producing an
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
export interface TimeSyncSample {
|
|
2
|
+
clientSendMonoMs: number;
|
|
3
|
+
clientReceiveMonoMs: number;
|
|
4
|
+
serverReceiveWallMs: number;
|
|
5
|
+
serverSendWallMs: number;
|
|
6
|
+
rttMs: number;
|
|
7
|
+
estimatedOffsetMs: number;
|
|
8
|
+
browserWallAtSyncMs: number;
|
|
9
|
+
}
|
|
10
|
+
export interface TimeSyncResponseBody {
|
|
11
|
+
requestId: string;
|
|
12
|
+
serverReceivedAtMs: number;
|
|
13
|
+
serverSentAtMs: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ClockSyncMetadata {
|
|
16
|
+
offsetMs: number | null;
|
|
17
|
+
rttMs: number | null;
|
|
18
|
+
syncAgeMs: number | null;
|
|
19
|
+
}
|
|
20
|
+
export interface PendingTimeSyncRequest {
|
|
21
|
+
requestId: string;
|
|
22
|
+
clientSendMonoMs: number;
|
|
23
|
+
}
|
|
24
|
+
export declare function monotonicNow(): number;
|
|
25
|
+
export declare function timeOriginMs(): number;
|
|
26
|
+
declare class ClockSyncManager {
|
|
27
|
+
private samples;
|
|
28
|
+
private pending;
|
|
29
|
+
private estimatedOffsetMs;
|
|
30
|
+
private bestRttMs;
|
|
31
|
+
private lastSyncMonoMs;
|
|
32
|
+
beginSync(requestId: string): PendingTimeSyncRequest;
|
|
33
|
+
abandonSync(requestId: string): void;
|
|
34
|
+
recordTimeSyncResponse(body: TimeSyncResponseBody): TimeSyncSample | null;
|
|
35
|
+
estimateServerTime(monoMs: number): number | null;
|
|
36
|
+
getSyncMetadata(): ClockSyncMetadata;
|
|
37
|
+
getBrowserClockSkewMs(): number | null;
|
|
38
|
+
reset(): void;
|
|
39
|
+
private pruneSamples;
|
|
40
|
+
private recompute;
|
|
41
|
+
}
|
|
42
|
+
export declare function getClockSyncManager(): ClockSyncManager;
|
|
43
|
+
export declare function estimateServerTime(monoMs: number): number | null;
|
|
44
|
+
export declare function getClockSyncMetadata(): ClockSyncMetadata;
|
|
45
|
+
export declare function buildEventTimeEnvelope(capturedMonoMs: number, capturedWallMs: number): {
|
|
46
|
+
client: {
|
|
47
|
+
wallTimeMs: number;
|
|
48
|
+
monotonicMs: number;
|
|
49
|
+
timeOriginMs: number;
|
|
50
|
+
};
|
|
51
|
+
serverEstimated: {
|
|
52
|
+
eventTimeMs: number | null;
|
|
53
|
+
offsetMs: number | null;
|
|
54
|
+
rttMs: number | null;
|
|
55
|
+
syncAgeMs: number | null;
|
|
56
|
+
};
|
|
57
|
+
};
|
|
58
|
+
export declare function captureClientTime(): {
|
|
59
|
+
wallTimeMs: number;
|
|
60
|
+
monotonicMs: number;
|
|
61
|
+
timeOriginMs: number;
|
|
62
|
+
};
|
|
63
|
+
export declare const __testing: {
|
|
64
|
+
reset: () => void;
|
|
65
|
+
MAX_USABLE_RTT_MS: number;
|
|
66
|
+
BEST_SAMPLE_COUNT: number;
|
|
67
|
+
HAS_WINDOW: boolean;
|
|
68
|
+
HAS_PERFORMANCE: boolean;
|
|
69
|
+
};
|
|
70
|
+
export {};
|
|
@@ -11,6 +11,7 @@ export declare function fetchIntegrationData(apiKey: string, backendApi: string)
|
|
|
11
11
|
export declare function refreshIntegrationData(apiKey: string, backendApi: string): Promise<EngineeringTicketIntegration | null>;
|
|
12
12
|
export declare function populateSelectOptions(selectElement: HTMLSelectElement, options: any[], defaultValue?: string): void;
|
|
13
13
|
export declare function populatePriorityOptions(selectElement: HTMLSelectElement, provider: string, defaultPriority?: number): void;
|
|
14
|
+
export declare function pickDefaultActiveSprint(sprints: any[], projectId: string, projects: any[]): any | null;
|
|
14
15
|
export declare function populateSprintOptions(selectElement: HTMLSelectElement, sprints: any[], currentValue?: string): void;
|
|
15
16
|
export declare function getSprintFieldId(): string;
|
|
16
17
|
export declare function updateIssueTypeOptions(selectElement: HTMLSelectElement, projectId: string): void;
|
|
@@ -5,8 +5,10 @@ export declare let recordingStartTime: number | null;
|
|
|
5
5
|
export declare let recordingEndTime: number | null;
|
|
6
6
|
export declare let timerInterval: ReturnType<typeof setInterval> | null;
|
|
7
7
|
export declare let isRecording: boolean;
|
|
8
|
+
export declare let sprintDefaulted: boolean;
|
|
8
9
|
export declare function setCurrentState(state: IssueReportState): void;
|
|
9
10
|
export declare function resetState(): void;
|
|
11
|
+
export declare function setSprintDefaulted(value: boolean): void;
|
|
10
12
|
export declare function setRecordingStartTime(time: number | null): void;
|
|
11
13
|
export declare function setRecordingEndTime(time: number | null): void;
|
|
12
14
|
export declare function setTimerInterval(interval: ReturnType<typeof setInterval> | null): void;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
import type { LogRecordOptions } from "@sailfish-rrweb/rrweb-plugin-console-record";
|
|
2
2
|
import type { ShortcutsConfig } from "./inAppReportIssueModal";
|
|
3
|
-
import { CaptureSettings } from "./types";
|
|
3
|
+
import { CaptureSettings, MaskInputOptions } from "./types";
|
|
4
4
|
export declare const STORAGE_VERSION = 1;
|
|
5
5
|
export declare const DEFAULT_CAPTURE_SETTINGS: CaptureSettings;
|
|
6
6
|
export declare const DEFAULT_CONSOLE_RECORDING_SETTINGS: LogRecordOptions;
|
|
7
|
+
export declare function toAbsoluteUrl(url: string): string;
|
|
7
8
|
export declare function matchUrlWithWildcard(input: unknown, patterns: string[]): boolean;
|
|
8
9
|
export declare function createSkipHeadersPropagationChecker(domainsToNotPropagateHeaderTo?: string[], domainsToPropagateHeaderTo?: string[]): (url: string) => boolean;
|
|
9
|
-
export declare function startRecording({ apiKey, backendApi, domainsToPropagateHeaderTo, domainsToNotPropagateHeaderTo, serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody, captureResponseBodyMaxMb, captureStreamPrefixKb, captureStreamTimeoutMs, enableFiberTracking, deferRecording, deferRecordingStart, chunkSnapshot, useWsWorker, capturePerformanceMetrics, maskTextClass, library, headlessRecording, }: {
|
|
10
|
+
export declare function startRecording({ apiKey, backendApi, domainsToPropagateHeaderTo, domainsToNotPropagateHeaderTo, serviceVersion, serviceIdentifier, gitSha, serviceAdditionalMetadata, enableIpTracking, captureStreamingResponseBody, captureResponseBodyMaxMb, captureStreamPrefixKb, captureStreamTimeoutMs, enableFiberTracking, deferRecording, deferRecordingStart, chunkSnapshot, useWsWorker, capturePerformanceMetrics, maskTextClass, maskInputSelector, maskTextSelector, blockSelector, unmaskSelector, maskInputOptions, library, headlessRecording, }: {
|
|
10
11
|
apiKey: string;
|
|
11
12
|
backendApi?: string;
|
|
12
13
|
domainsToPropagateHeaderTo?: string[];
|
|
@@ -29,6 +30,11 @@ export declare function startRecording({ apiKey, backendApi, domainsToPropagateH
|
|
|
29
30
|
useWsWorker?: boolean;
|
|
30
31
|
capturePerformanceMetrics?: boolean;
|
|
31
32
|
maskTextClass?: string;
|
|
33
|
+
maskInputSelector?: string;
|
|
34
|
+
maskTextSelector?: string;
|
|
35
|
+
blockSelector?: string;
|
|
36
|
+
unmaskSelector?: string;
|
|
37
|
+
maskInputOptions?: MaskInputOptions;
|
|
32
38
|
headlessRecording?: boolean;
|
|
33
39
|
}): Promise<void>;
|
|
34
40
|
export declare const initRecorder: (options: {
|
|
@@ -99,6 +105,14 @@ export declare const initRecorder: (options: {
|
|
|
99
105
|
* @default false
|
|
100
106
|
*/
|
|
101
107
|
headlessRecording?: boolean;
|
|
108
|
+
/**
|
|
109
|
+
* Enable internal debug console logs for diagnosing recorder bugs
|
|
110
|
+
* (e.g. duplicate page_visit_uuid mints, missed URL changes). Off by
|
|
111
|
+
* default so customer integrations see no console noise. Sailfish's
|
|
112
|
+
* own FE may set this to true to surface mint events with stack traces.
|
|
113
|
+
* @default false
|
|
114
|
+
*/
|
|
115
|
+
enableInternalDebugLogs?: boolean;
|
|
102
116
|
}) => Promise<void>;
|
|
103
117
|
export * from "./graphql";
|
|
104
118
|
export { openReportIssueModal } from "./inAppReportIssueModal";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Match the shape of rrweb's MaskInputOptions — only the keys we care about. */
|
|
2
|
+
export interface MaskInputOptionsLike {
|
|
3
|
+
[typeOrTag: string]: boolean | undefined;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Safe ancestor-match check. A malformed selector — typo'd by an admin in
|
|
7
|
+
* the Form Privacy Rules UI — would otherwise throw from `closest()` and
|
|
8
|
+
* crash rrweb's serializer mid-walk. Under-masking is better than dropping
|
|
9
|
+
* the session.
|
|
10
|
+
*/
|
|
11
|
+
export declare function closestSafe(el: HTMLElement | null, selector?: string): boolean;
|
|
12
|
+
/** Element.matches with try/catch for malformed selectors. */
|
|
13
|
+
export declare function matchesSelectorSafe(el: HTMLElement, selector?: string): boolean;
|
|
14
|
+
/**
|
|
15
|
+
* Build the maskInputFn we hand to rrweb. rrweb only calls maskInputFn after
|
|
16
|
+
* its own check on `maskInputOptions[tagName] || maskInputOptions[type]` —
|
|
17
|
+
* it has NO `maskInputSelector` concept. To honor admin-set
|
|
18
|
+
* `maskInputSelector` in normal record() mode, callers expand the
|
|
19
|
+
* rrweb-facing `maskInputOptions` to all-true so rrweb routes EVERY input
|
|
20
|
+
* through this fn. This function then enforces the real per-element decision:
|
|
21
|
+
*
|
|
22
|
+
* 1. unmaskSelector match → raw (admin opted this subtree out)
|
|
23
|
+
* 2. type in admin-intent maskInputOptions OR maskInputSelector match → mask
|
|
24
|
+
* 3. otherwise → raw (admin didn't configure this field for masking)
|
|
25
|
+
*
|
|
26
|
+
* `baseFn` produces the masked string (PII-specific last-4 CC/SSN/DOB, or
|
|
27
|
+
* a full-asterisk fallback).
|
|
28
|
+
*/
|
|
29
|
+
export declare function buildMaskInputFn(opts: {
|
|
30
|
+
baseFn: (text: string, node: HTMLElement) => string;
|
|
31
|
+
unmaskSelector?: string;
|
|
32
|
+
maskInputSelector?: string;
|
|
33
|
+
/** ORIGINAL admin intent — NOT the all-on expansion sent to rrweb. */
|
|
34
|
+
adminMaskInputOptions: MaskInputOptionsLike;
|
|
35
|
+
}): (text: string, node: HTMLElement) => string;
|
|
36
|
+
/**
|
|
37
|
+
* @deprecated Use buildMaskInputFn. Kept so older test imports keep working
|
|
38
|
+
* during the transition.
|
|
39
|
+
*/
|
|
40
|
+
export declare function wrapMaskInputFn(baseFn: (text: string, node: HTMLElement) => string, unmaskSelector?: string): (text: string, node: HTMLElement) => string;
|
|
41
|
+
/**
|
|
42
|
+
* Default maskTextFn — replaces every non-whitespace char with "*",
|
|
43
|
+
* matching rrweb's built-in behavior. Wrapped so that nodes under
|
|
44
|
+
* `unmaskSelector` skip masking.
|
|
45
|
+
*/
|
|
46
|
+
export declare function makeMaskTextFn(unmaskSelector?: string): (text: string, element: HTMLElement | null) => string;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LogRecordOptions } from "@sailfish-rrweb/rrweb-plugin-console-record";
|
|
2
2
|
import type { WsHandle } from "./websocket";
|
|
3
|
+
export declare function maskInputFn(text: string, node: HTMLElement): string;
|
|
3
4
|
/** Call after writing to sessionStorage keys used by getUrlAndStoredUuids(). */
|
|
4
5
|
export declare function invalidateUrlCache(): void;
|
|
5
6
|
export declare const getUrlAndStoredUuids: () => {
|
package/dist/types/types.d.ts
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
export type MaskInputOptions = Partial<{
|
|
2
|
+
color: boolean;
|
|
3
|
+
date: boolean;
|
|
4
|
+
"datetime-local": boolean;
|
|
5
|
+
email: boolean;
|
|
6
|
+
month: boolean;
|
|
7
|
+
number: boolean;
|
|
8
|
+
range: boolean;
|
|
9
|
+
search: boolean;
|
|
10
|
+
tel: boolean;
|
|
11
|
+
text: boolean;
|
|
12
|
+
time: boolean;
|
|
13
|
+
url: boolean;
|
|
14
|
+
week: boolean;
|
|
15
|
+
textarea: boolean;
|
|
16
|
+
select: boolean;
|
|
17
|
+
password: boolean;
|
|
18
|
+
}>;
|
|
1
19
|
export interface CaptureSettings {
|
|
2
20
|
recordCanvas: boolean;
|
|
3
21
|
recordCrossOriginIframes: boolean;
|
|
@@ -12,6 +30,11 @@ export interface CaptureSettings {
|
|
|
12
30
|
textEditThrottleEnabled?: boolean;
|
|
13
31
|
enableFiberTracking?: boolean;
|
|
14
32
|
maskTextClass?: string;
|
|
33
|
+
maskInputSelector?: string;
|
|
34
|
+
maskTextSelector?: string;
|
|
35
|
+
blockSelector?: string;
|
|
36
|
+
unmaskSelector?: string;
|
|
37
|
+
maskInputOptions?: MaskInputOptions;
|
|
15
38
|
}
|
|
16
39
|
export interface ConsoleRecordSettings {
|
|
17
40
|
level: string[];
|
|
@@ -3,6 +3,7 @@ export interface WsHandle {
|
|
|
3
3
|
readyState: number;
|
|
4
4
|
close: () => void;
|
|
5
5
|
}
|
|
6
|
+
export declare function requestTimeSync(): void;
|
|
6
7
|
type NavigationCallback = () => void;
|
|
7
8
|
/** Register a callback to be invoked immediately on pushState/replaceState/popstate/hashchange. */
|
|
8
9
|
export declare function onNavigationChange(cb: NavigationCallback): void;
|
package/dist/websocket.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ReconnectingWebSocket from "reconnecting-websocket";
|
|
2
|
+
import { buildEventTimeEnvelope, getClockSyncManager, monotonicNow, timeOriginMs, } from "./clockSync";
|
|
2
3
|
import { readDebugFlag } from "./env";
|
|
3
4
|
import { deleteEventsByIds, getAllIndexedEvents, saveEventsToIDB, } from "./eventStore";
|
|
4
5
|
import { deleteNotifyMessageById, getAllNotifyMessages, saveNotifyMessageToIDB, } from "./notifyEventStore";
|
|
@@ -96,6 +97,95 @@ let webSocket = null;
|
|
|
96
97
|
let isDraining = false;
|
|
97
98
|
let inFlightFlush = null;
|
|
98
99
|
let flushIntervalId = null;
|
|
100
|
+
// ─── Time sync state ─────────────────────────────────────────────────────────
|
|
101
|
+
// Periodic re-sync of the browser→server clock offset. The first burst
|
|
102
|
+
// happens in handleWsOpen so events flushed from IDB on reconnect get a
|
|
103
|
+
// reasonable estimate immediately.
|
|
104
|
+
const TIME_SYNC_INTERVAL_MS = 60 * 1000;
|
|
105
|
+
const TIME_SYNC_OPEN_BURST_COUNT = 3;
|
|
106
|
+
const TIME_SYNC_OPEN_BURST_SPACING_MS = 250;
|
|
107
|
+
let timeSyncIntervalId = null;
|
|
108
|
+
let _timeSyncRequestCounter = 0;
|
|
109
|
+
function makeTimeSyncRequestId() {
|
|
110
|
+
_timeSyncRequestCounter += 1;
|
|
111
|
+
return `ts-${_timeSyncRequestCounter}-${Math.random().toString(36).slice(2, 10)}`;
|
|
112
|
+
}
|
|
113
|
+
// Fire one NTP-style request through the existing socket. Response will be
|
|
114
|
+
// dispatched in handleWsMessage when "time-sync-response" arrives.
|
|
115
|
+
export function requestTimeSync() {
|
|
116
|
+
if (!isWebSocketOpen(webSocket))
|
|
117
|
+
return;
|
|
118
|
+
const mgr = getClockSyncManager();
|
|
119
|
+
const requestId = makeTimeSyncRequestId();
|
|
120
|
+
mgr.beginSync(requestId);
|
|
121
|
+
const ok = wsSendPayload({
|
|
122
|
+
type: "time-sync-request",
|
|
123
|
+
requestId,
|
|
124
|
+
clientSentAtMs: Date.now(),
|
|
125
|
+
});
|
|
126
|
+
if (!ok)
|
|
127
|
+
mgr.abandonSync(requestId);
|
|
128
|
+
}
|
|
129
|
+
function startTimeSyncInterval() {
|
|
130
|
+
if (timeSyncIntervalId != null || typeof window === "undefined")
|
|
131
|
+
return;
|
|
132
|
+
timeSyncIntervalId = window.setInterval(() => {
|
|
133
|
+
requestTimeSync();
|
|
134
|
+
}, TIME_SYNC_INTERVAL_MS);
|
|
135
|
+
}
|
|
136
|
+
function stopTimeSyncInterval() {
|
|
137
|
+
if (timeSyncIntervalId != null && typeof window !== "undefined") {
|
|
138
|
+
window.clearInterval(timeSyncIntervalId);
|
|
139
|
+
timeSyncIntervalId = null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function kickoffTimeSyncBurst() {
|
|
143
|
+
if (typeof window === "undefined")
|
|
144
|
+
return;
|
|
145
|
+
for (let i = 0; i < TIME_SYNC_OPEN_BURST_COUNT; i++) {
|
|
146
|
+
window.setTimeout(requestTimeSync, i * TIME_SYNC_OPEN_BURST_SPACING_MS);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
// ─── Event time-sync enrichment ──────────────────────────────────────────────
|
|
150
|
+
// Stamps client.{wallTime,monotonic,timeOrigin} and serverEstimated.* onto an
|
|
151
|
+
// outbound event.
|
|
152
|
+
//
|
|
153
|
+
// Idempotent for client.*: if the capture site already populated
|
|
154
|
+
// client.monotonicMs we keep that timing as-is.
|
|
155
|
+
//
|
|
156
|
+
// Re-evaluating for serverEstimated.*: if the envelope was attached when no
|
|
157
|
+
// sync sample had landed yet (eventTimeMs === null), and an offset is now
|
|
158
|
+
// available, recompute. This is the common page-load case — events fire in
|
|
159
|
+
// the first few hundred ms before the first time-sync-request gets a reply,
|
|
160
|
+
// get queued to IDB with a null estimate, then are flushed once the WS is
|
|
161
|
+
// open and the offset is known. Without this, those events would carry
|
|
162
|
+
// eventTimeMs=null forever.
|
|
163
|
+
//
|
|
164
|
+
// Cross-page-load events (different performance.timeOrigin from the current
|
|
165
|
+
// context) are skipped — the captured monotonicMs is in a different domain
|
|
166
|
+
// than the current offset, so re-deriving would produce garbage.
|
|
167
|
+
function enrichEventWithTimeSync(event) {
|
|
168
|
+
if (!event || typeof event !== "object")
|
|
169
|
+
return;
|
|
170
|
+
if (event.client && event.client.monotonicMs != null) {
|
|
171
|
+
const sameContext = event.client.timeOriginMs == null ||
|
|
172
|
+
Math.abs(event.client.timeOriginMs - timeOriginMs()) <= 1;
|
|
173
|
+
if (!sameContext)
|
|
174
|
+
return;
|
|
175
|
+
const env = buildEventTimeEnvelope(event.client.monotonicMs, event.client.wallTimeMs ?? Date.now());
|
|
176
|
+
if (!event.serverEstimated) {
|
|
177
|
+
event.serverEstimated = env.serverEstimated;
|
|
178
|
+
}
|
|
179
|
+
else if (event.serverEstimated.eventTimeMs == null &&
|
|
180
|
+
env.serverEstimated.eventTimeMs != null) {
|
|
181
|
+
event.serverEstimated = env.serverEstimated;
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const env = buildEventTimeEnvelope(monotonicNow(), Date.now());
|
|
186
|
+
event.client = env.client;
|
|
187
|
+
event.serverEstimated = env.serverEstimated;
|
|
188
|
+
}
|
|
99
189
|
// ─── Cached window.location.href ──────────────────────────────────────────────
|
|
100
190
|
// Updated on navigation events (popstate, hashchange, pushState, replaceState)
|
|
101
191
|
// instead of reading the DOM property on every sendEvent() call.
|
|
@@ -404,6 +494,11 @@ export async function flushBufferedEvents() {
|
|
|
404
494
|
const eventsToSend = batch.map((e) => {
|
|
405
495
|
if (!e.data.appUrl)
|
|
406
496
|
e.data.appUrl = getCachedHref();
|
|
497
|
+
// Re-derive serverEstimated against the now-current offset. Most
|
|
498
|
+
// page-load events were queued before the first sync sample landed,
|
|
499
|
+
// baking eventTimeMs=null into their envelope; this is the only
|
|
500
|
+
// chance to give them a real estimate before they leave the SDK.
|
|
501
|
+
enrichEventWithTimeSync(e.data);
|
|
407
502
|
return e.data;
|
|
408
503
|
});
|
|
409
504
|
const idsToDelete = batch
|
|
@@ -435,6 +530,7 @@ export function sendEvent(event) {
|
|
|
435
530
|
// Mutate in place — event is ephemeral, avoids object spread allocation
|
|
436
531
|
if (!event.app_url)
|
|
437
532
|
event.app_url = getCachedHref();
|
|
533
|
+
enrichEventWithTimeSync(event);
|
|
438
534
|
if (isDraining || !isWebSocketOpen(webSocket)) {
|
|
439
535
|
queueEventForIDB(event);
|
|
440
536
|
return;
|
|
@@ -470,18 +566,32 @@ function handleWsOpen() {
|
|
|
470
566
|
void flushBufferedEvents();
|
|
471
567
|
}, 2000);
|
|
472
568
|
})();
|
|
569
|
+
// Seed the clock-offset estimate immediately so subsequent events get a
|
|
570
|
+
// real serverEstimated.eventTimeMs instead of nulls.
|
|
571
|
+
kickoffTimeSyncBurst();
|
|
572
|
+
startTimeSyncInterval();
|
|
473
573
|
}
|
|
474
574
|
function handleWsClose() {
|
|
475
575
|
if (flushIntervalId != null) {
|
|
476
576
|
clearInterval(flushIntervalId);
|
|
477
577
|
flushIntervalId = null;
|
|
478
578
|
}
|
|
579
|
+
stopTimeSyncInterval();
|
|
479
580
|
if (DEBUG)
|
|
480
581
|
console.log("[Sailfish] WebSocket closed");
|
|
481
582
|
}
|
|
482
583
|
function handleWsMessage(rawData) {
|
|
483
584
|
try {
|
|
484
585
|
const data = JSON.parse(rawData);
|
|
586
|
+
// Handle NTP-style time-sync handshake reply from backend
|
|
587
|
+
if (data.type === "time-sync-response") {
|
|
588
|
+
getClockSyncManager().recordTimeSyncResponse({
|
|
589
|
+
requestId: data.requestId,
|
|
590
|
+
serverReceivedAtMs: data.serverReceivedAtMs,
|
|
591
|
+
serverSentAtMs: data.serverSentAtMs,
|
|
592
|
+
});
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
485
595
|
// Handle function span tracking control messages from backend
|
|
486
596
|
if (data.type === "funcSpanTrackingControl") {
|
|
487
597
|
if (DEBUG) {
|
|
@@ -695,6 +805,7 @@ export function sendMessage(message) {
|
|
|
695
805
|
// Mutate in place — avoids object spread allocation
|
|
696
806
|
if (!message.app_url)
|
|
697
807
|
message.app_url = getCachedHref();
|
|
808
|
+
enrichEventWithTimeSync(message);
|
|
698
809
|
if (isDraining || !isWebSocketOpen(webSocket)) {
|
|
699
810
|
saveNotifyMessageToIDB(JSON.stringify(message));
|
|
700
811
|
}
|
package/dist/websocket.js.br
CHANGED
|
Binary file
|
package/dist/websocket.js.gz
CHANGED
|
Binary file
|