@sailfish-ai/recorder 1.11.6 → 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 (72) 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-CftVmmO9.js → index-C-qbsfKe.js} +703 -542
  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-QXHuV98g.js → index-D6axlCRu.js} +751 -586
  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/graphql.js +5 -0
  21. package/dist/graphql.js.br +0 -0
  22. package/dist/graphql.js.gz +0 -0
  23. package/dist/inAppReportIssueModal/index.js +4 -1
  24. package/dist/inAppReportIssueModal/index.js.br +0 -0
  25. package/dist/inAppReportIssueModal/index.js.gz +0 -0
  26. package/dist/inAppReportIssueModal/integrations.js +36 -0
  27. package/dist/inAppReportIssueModal/integrations.js.br +0 -0
  28. package/dist/inAppReportIssueModal/integrations.js.gz +0 -0
  29. package/dist/inAppReportIssueModal/state.js +8 -0
  30. package/dist/inAppReportIssueModal/state.js.br +0 -0
  31. package/dist/inAppReportIssueModal/state.js.gz +0 -0
  32. package/dist/index.js +67 -5
  33. package/dist/index.js.br +0 -0
  34. package/dist/index.js.gz +0 -0
  35. package/dist/privacyMask.js +93 -0
  36. package/dist/privacyMask.js.br +0 -0
  37. package/dist/privacyMask.js.gz +0 -0
  38. package/dist/recorder.cjs +2 -2
  39. package/dist/recorder.cjs.br +0 -0
  40. package/dist/recorder.cjs.gz +0 -0
  41. package/dist/recorder.js +25 -22
  42. package/dist/recorder.js.br +0 -0
  43. package/dist/recorder.js.gz +0 -0
  44. package/dist/recorder.umd.cjs +1310 -1127
  45. package/dist/recorder.umd.cjs.br +0 -0
  46. package/dist/recorder.umd.cjs.gz +0 -0
  47. package/dist/recording.js +84 -13
  48. package/dist/recording.js.br +0 -0
  49. package/dist/recording.js.gz +0 -0
  50. package/dist/types/chunkSerializer.d.ts +14 -0
  51. package/dist/types/clockSync.d.ts +70 -0
  52. package/dist/types/inAppReportIssueModal/integrations.d.ts +1 -0
  53. package/dist/types/inAppReportIssueModal/state.d.ts +2 -0
  54. package/dist/types/index.d.ts +16 -2
  55. package/dist/types/privacyMask.d.ts +46 -0
  56. package/dist/types/recording.d.ts +1 -0
  57. package/dist/types/types.d.ts +23 -0
  58. package/dist/types/websocket.d.ts +1 -0
  59. package/dist/websocket.js +111 -0
  60. package/dist/websocket.js.br +0 -0
  61. package/dist/websocket.js.gz +0 -0
  62. package/package.json +1 -1
  63. package/dist/chunks/chunkSerializer-D7_uK-dD.js +0 -94
  64. package/dist/chunks/chunkSerializer-D7_uK-dD.js.br +0 -0
  65. package/dist/chunks/chunkSerializer-D7_uK-dD.js.gz +0 -0
  66. package/dist/chunks/chunkSerializer-DkWXHnW4.js +0 -95
  67. package/dist/chunks/chunkSerializer-DkWXHnW4.js.br +0 -0
  68. package/dist/chunks/chunkSerializer-DkWXHnW4.js.gz +0 -0
  69. package/dist/chunks/index-CftVmmO9.js.br +0 -0
  70. package/dist/chunks/index-CftVmmO9.js.gz +0 -0
  71. package/dist/chunks/index-QXHuV98g.js.br +0 -0
  72. package/dist/chunks/index-QXHuV98g.js.gz +0 -0
Binary file
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
- const MASK_CLASS = "mask";
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: captureSettings.blockSelector,
424
+ blockSelector,
363
425
  maskTextClass: captureSettings.maskTextClass ?? MASK_CLASS,
364
- maskTextSelector: captureSettings.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
  }
Binary file
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;
@@ -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: () => {
@@ -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
  }
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sailfish-ai/recorder",
3
- "version": "1.11.6",
3
+ "version": "1.12.4",
4
4
  "publishPublicly": true,
5
5
  "publishToCdn": {
6
6
  "jsdelivr": true,