@sessionsight/insights 1.0.0

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.
@@ -0,0 +1,1396 @@
1
+ import { record } from 'rrweb';
2
+ import type { eventWithTime } from 'rrweb';
3
+ import { WorkerBridge } from './worker-bridge.js';
4
+ import { getRegistryValue } from '@sessionsight/sdk-shared';
5
+ import type { SessionMetadata, RecordOptions, PrivacyConfig } from './types.js';
6
+
7
+ const PRE_BUFFER_MAX_MS = 5_000;
8
+ const FLAG_CHECK_INTERVAL_MS = 6_000;
9
+
10
+ // rrweb EventType constants
11
+ const META_EVENT_TYPE = 4;
12
+ const CUSTOM_EVENT_TYPE = 5;
13
+ // rrweb FullSnapshot type
14
+ const FULL_SNAPSHOT_EVENT_TYPE = 2;
15
+ // rrweb IncrementalSnapshot type
16
+ const INCREMENTAL_SNAPSHOT_EVENT_TYPE = 3;
17
+ // rrweb IncrementalSource.Mutation
18
+ const MUTATION_SOURCE = 0;
19
+ // rrweb serialized NodeType.Element
20
+ const SERIALIZED_ELEMENT_TYPE = 2;
21
+
22
+ // ── PII Detection & Redaction ─────────────────────────────────────
23
+ // These patterns are always applied regardless of privacy mode or
24
+ // data-ss-unmask. They cannot be overridden by the site owner.
25
+
26
+ function luhnCheck(digits: string): boolean {
27
+ let sum = 0;
28
+ let alternate = false;
29
+ for (let i = digits.length - 1; i >= 0; i--) {
30
+ let n = parseInt(digits[i]!, 10);
31
+ if (alternate) {
32
+ n *= 2;
33
+ if (n > 9) n -= 9;
34
+ }
35
+ sum += n;
36
+ alternate = !alternate;
37
+ }
38
+ return sum % 10 === 0;
39
+ }
40
+
41
+ // Credit cards: 13-19 digit sequences (with optional spaces/dashes) validated by Luhn
42
+ const CREDIT_CARD_RE = /\b(\d[\d\s\-]{11,22}\d)\b/g;
43
+
44
+ // SSN: 3-2-4 digit pattern. Area number cannot be 000, 666, or 900-999.
45
+ const SSN_RE = /\b(?!000|666|9\d\d)(\d{3})[- ]?(?!00)(\d{2})[- ]?(?!0000)(\d{4})\b/g;
46
+
47
+ // Email addresses
48
+ const EMAIL_RE = /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/g;
49
+
50
+ // Phone numbers: US 10-digit (with optional +1 country code)
51
+ const PHONE_US_RE = /(?:\+?1[\s.\-]?)?\(?\d{3}\)?[\s.\-]?\d{3}[\s.\-]?\d{4}\b/g;
52
+ // International: +country code followed by 7-14 digits with optional separators
53
+ const PHONE_INTL_RE = /\+\d{1,3}[\s.\-]\d[\d\s.\-]{6,16}\d\b/g;
54
+
55
+ // IBAN: 2-letter country code + 2 check digits + up to 30 alphanumeric (with optional spaces/dashes)
56
+ const IBAN_RE = /\b[A-Z]{2}\d{2}[\s\-]?[\dA-Z]{4}[\s\-]?(?:[\dA-Z]{4}[\s\-]?){1,7}[\dA-Z]{1,4}\b/g;
57
+
58
+ // API keys and tokens from common providers
59
+ const API_KEY_RE = /\b(?:sk-[A-Za-z0-9]{20,}|sk_(?:live|test)_[A-Za-z0-9]{20,}|pk_(?:live|test)_[A-Za-z0-9]{20,}|ghp_[A-Za-z0-9]{36,}|gho_[A-Za-z0-9]{36,}|AKIA[A-Z0-9]{16})\b/g;
60
+
61
+ const REDACTED = '[REDACTED]';
62
+
63
+ /**
64
+ * Redact all recognized PII patterns from text. This runs on every code path
65
+ * (including data-ss-unmask) so sensitive data never reaches the server.
66
+ */
67
+ function redactPII(text: string): string {
68
+ // IBAN first: the numeric portion of an IBAN can look like a credit card
69
+ // to the Luhn check, so redact IBANs before credit cards to avoid partial matches.
70
+ text = text.replace(IBAN_RE, REDACTED);
71
+
72
+ // Credit cards (Luhn-validated)
73
+ text = text.replace(CREDIT_CARD_RE, (match) => {
74
+ const digits = match.replace(/[\s\-]/g, '');
75
+ if (digits.length >= 13 && digits.length <= 19 && luhnCheck(digits)) {
76
+ return REDACTED;
77
+ }
78
+ return match;
79
+ });
80
+
81
+ text = text.replace(SSN_RE, REDACTED);
82
+ text = text.replace(EMAIL_RE, REDACTED);
83
+ text = text.replace(PHONE_US_RE, REDACTED);
84
+ text = text.replace(PHONE_INTL_RE, (match) => {
85
+ const digits = match.replace(/\D/g, '');
86
+ // Must have 8-15 digits total (E.164 range) to avoid matching arbitrary number sequences
87
+ if (digits.length >= 8 && digits.length <= 15) return REDACTED;
88
+ return match;
89
+ });
90
+ text = text.replace(API_KEY_RE, REDACTED);
91
+
92
+ return text;
93
+ }
94
+
95
+ /**
96
+ * Width-class character mapping for proportional fonts.
97
+ * Characters are grouped by approximate rendered width in common sans-serif fonts.
98
+ * Scrambling swaps within the same width class to minimize layout drift.
99
+ */
100
+ const WIDTH_CLASSES_LOWER: Record<string, string[]> = {
101
+ narrow: ['i', 'j', 'l'],
102
+ semiNarrow: ['f', 'r', 't'],
103
+ medium: ['a', 'b', 'c', 'd', 'e', 'g', 'h', 'k', 'n', 'o', 'p', 'q', 's', 'u', 'v', 'x', 'y', 'z'],
104
+ wide: ['m', 'w'],
105
+ };
106
+
107
+ const WIDTH_CLASSES_UPPER: Record<string, string[]> = {
108
+ narrow: ['I', 'J'],
109
+ semiNarrow: ['E', 'F', 'L', 'T'],
110
+ medium: ['A', 'B', 'C', 'D', 'G', 'H', 'K', 'N', 'O', 'P', 'Q', 'R', 'S', 'U', 'V', 'X', 'Y', 'Z'],
111
+ wide: ['M', 'W'],
112
+ };
113
+
114
+ // Build lookup: char -> array of same-width-class chars
115
+ const SCRAMBLE_MAP = new Map<string, string[]>();
116
+ for (const group of Object.values(WIDTH_CLASSES_LOWER)) {
117
+ for (const ch of group) SCRAMBLE_MAP.set(ch, group);
118
+ }
119
+ for (const group of Object.values(WIDTH_CLASSES_UPPER)) {
120
+ for (const ch of group) SCRAMBLE_MAP.set(ch, group);
121
+ }
122
+
123
+ /**
124
+ * Scramble text by replacing each character with another character of similar rendered width.
125
+ * Preserves whitespace, punctuation, and character count. Digits rotate within 0-9.
126
+ * This minimizes layout drift while ensuring no PII reaches the server.
127
+ */
128
+ function scrambleText(text: string): string {
129
+ // Preserve [REDACTED] tokens inserted by redactPII so they stay readable.
130
+ if (text.includes(REDACTED)) {
131
+ return text.split(REDACTED).map(part => scrambleText(part)).join(REDACTED);
132
+ }
133
+
134
+ let result = '';
135
+ for (let i = 0; i < text.length; i++) {
136
+ const ch = text[i]!;
137
+ const widthClass = SCRAMBLE_MAP.get(ch);
138
+ if (widthClass) {
139
+ // Pick a different character from the same width class
140
+ const idx = widthClass.indexOf(ch);
141
+ result += widthClass[(idx + 1) % widthClass.length]!;
142
+ } else {
143
+ const code = ch.charCodeAt(0);
144
+ if (code >= 48 && code <= 57) {
145
+ // Digits: rotate by 3
146
+ result += String.fromCharCode(48 + ((code - 48 + 3) % 10));
147
+ } else {
148
+ // Whitespace, punctuation, symbols, non-Latin: keep as-is
149
+ result += ch;
150
+ }
151
+ }
152
+ }
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * Resolve the masking directive for an element by walking up to the nearest
158
+ * data-ss-mask / data-ss-unmask ancestor. Returns 'mask', 'unmask', or null
159
+ * (use privacy mode default).
160
+ */
161
+ function resolveMaskDirective(element: HTMLElement | null): 'mask' | 'unmask' | null {
162
+ if (!element) return null;
163
+ const nearest = element.closest('[data-ss-mask], [data-ss-unmask]');
164
+ if (!nearest) return null;
165
+ return nearest.hasAttribute('data-ss-mask') ? 'mask' : 'unmask';
166
+ }
167
+
168
+ // ── Masking Cache ────────────────────────────────────────────────
169
+ // Most text nodes on a page are static between snapshots. Caching the
170
+ // masking result avoids re-running 7 PII regexes + char-by-char scrambling
171
+ // on every 30s checkout and mutation batch.
172
+
173
+ const MASK_CACHE_MAX = 4_000;
174
+ const maskCache = new Map<string, string>();
175
+
176
+ function maskCacheGet(key: string): string | undefined {
177
+ return maskCache.get(key);
178
+ }
179
+
180
+ function maskCachePut(key: string, value: string): void {
181
+ if (maskCache.size >= MASK_CACHE_MAX) {
182
+ // Evict oldest quarter of entries (Map iterates in insertion order)
183
+ const evictCount = MASK_CACHE_MAX / 4;
184
+ let i = 0;
185
+ for (const k of maskCache.keys()) {
186
+ if (i++ >= evictCount) break;
187
+ maskCache.delete(k);
188
+ }
189
+ }
190
+ maskCache.set(key, value);
191
+ }
192
+
193
+ function maskCacheClear(): void {
194
+ maskCache.clear();
195
+ }
196
+
197
+ /**
198
+ * Apply the privacy masking decision for a given DOM element.
199
+ * Checks data-ss-mask / data-ss-unmask on the element and its ancestors,
200
+ * then falls back to the privacy mode default. Results are cached by
201
+ * (text, directive, privacyMode) to avoid redundant regex + scramble work.
202
+ */
203
+ function applyMasking(text: string, element: HTMLElement | null, privacyMode: string): string {
204
+ const directive = resolveMaskDirective(element);
205
+ const cacheKey = `${directive ?? 'd'}:${privacyMode}:${text}`;
206
+ const cached = maskCacheGet(cacheKey);
207
+ if (cached !== undefined) return cached;
208
+
209
+ // Always redact PII first, before any other masking. This ensures sensitive
210
+ // patterns are caught even when text is about to be scrambled.
211
+ let result = redactPII(text);
212
+
213
+ if (directive === 'mask') {
214
+ result = scrambleText(result);
215
+ } else if (directive === 'unmask') {
216
+ // result already has PII redacted, no scramble needed
217
+ } else if (privacyMode === 'default') {
218
+ result = scrambleText(result);
219
+ }
220
+
221
+ maskCachePut(cacheKey, result);
222
+ return result;
223
+ }
224
+
225
+ // ── Serialized Event Placeholder Masking ──────────────────────────
226
+
227
+ type SerializedNode = {
228
+ type: number;
229
+ tagName?: string;
230
+ attributes?: Record<string, any>;
231
+ childNodes?: SerializedNode[];
232
+ id?: number;
233
+ };
234
+
235
+ const PLACEHOLDER_INPUT_TAGS = new Set(['input', 'textarea']);
236
+
237
+ /**
238
+ * Recursively walk a serialized rrweb node tree and scramble placeholder
239
+ * attributes on input/textarea elements. Respects data-ss-mask / data-ss-unmask
240
+ * attributes on the element itself and inherited from ancestor nodes.
241
+ *
242
+ * @param maskState - inherited masking: 'mask', 'unmask', or null (use mode default)
243
+ */
244
+ function scramblePlaceholders(
245
+ node: SerializedNode,
246
+ privacyMode: string,
247
+ maskState: 'mask' | 'unmask' | null,
248
+ ): void {
249
+ if (node.type === SERIALIZED_ELEMENT_TYPE) {
250
+ // Update inherited mask state if this element has a data-ss attribute
251
+ const attrs = node.attributes;
252
+ if (attrs) {
253
+ if ('data-ss-unmask' in attrs) maskState = 'unmask';
254
+ else if ('data-ss-mask' in attrs) maskState = 'mask';
255
+ }
256
+
257
+ // Scramble placeholder if this is an input/textarea
258
+ if (attrs && PLACEHOLDER_INPUT_TAGS.has(node.tagName!) && typeof attrs.placeholder === 'string' && attrs.placeholder) {
259
+ // Always redact PII in placeholders
260
+ attrs.placeholder = redactPII(attrs.placeholder);
261
+ const shouldScramble =
262
+ maskState === 'mask' ||
263
+ (maskState !== 'unmask' && privacyMode === 'default');
264
+ if (shouldScramble) {
265
+ attrs.placeholder = scrambleText(attrs.placeholder);
266
+ }
267
+ }
268
+ }
269
+
270
+ // Recurse into children for all node types (Document, Element, etc.)
271
+ if (node.childNodes) {
272
+ for (const child of node.childNodes) {
273
+ scramblePlaceholders(child, privacyMode, maskState);
274
+ }
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Process an rrweb event to scramble placeholder attributes on serialized
280
+ * input/textarea nodes. Handles FullSnapshot and IncrementalSnapshot (Mutation adds
281
+ * and attribute changes).
282
+ */
283
+ function maskEventPlaceholders(event: eventWithTime, privacyMode: string): void {
284
+ // FullSnapshot: walk the entire serialized DOM tree
285
+ if (event.type === FULL_SNAPSHOT_EVENT_TYPE) {
286
+ const data = event.data as { node?: SerializedNode };
287
+ if (data.node) scramblePlaceholders(data.node, privacyMode, null);
288
+ return;
289
+ }
290
+
291
+ // IncrementalSnapshot with Mutation source: walk added nodes and attribute changes
292
+ if (event.type === INCREMENTAL_SNAPSHOT_EVENT_TYPE) {
293
+ const data = event.data as { source?: number; adds?: { node: SerializedNode }[]; attributes?: { id: number; attributes: Record<string, any> }[] };
294
+ if (data.source !== MUTATION_SOURCE) return;
295
+
296
+ // Added nodes can contain input/textarea with placeholders
297
+ if (data.adds) {
298
+ for (const add of data.adds) {
299
+ scramblePlaceholders(add.node, privacyMode, null);
300
+ }
301
+ }
302
+
303
+ // Attribute mutations can set placeholder directly
304
+ if (data.attributes) {
305
+ for (const attr of data.attributes) {
306
+ if (typeof attr.attributes.placeholder === 'string' && attr.attributes.placeholder) {
307
+ attr.attributes.placeholder = redactPII(attr.attributes.placeholder);
308
+ const shouldScramble = privacyMode === 'default';
309
+ if (shouldScramble) {
310
+ attr.attributes.placeholder = scrambleText(attr.attributes.placeholder);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ // ── Page Exclusion Pattern Matching ────────────────────────────────
319
+
320
+ function globToRegex(pattern: string): RegExp | null {
321
+ try {
322
+ // Escape regex special chars except *, then convert * to .*
323
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
324
+ const regexStr = '^' + escaped.replace(/\*/g, '.*') + '$';
325
+ return new RegExp(regexStr);
326
+ } catch (e) {
327
+ console.warn('SessionSight: invalid excludePages pattern', pattern, e);
328
+ return null;
329
+ }
330
+ }
331
+
332
+ function matchesAnyPattern(path: string, patterns: RegExp[]): boolean {
333
+ for (const regex of patterns) {
334
+ if (regex.test(path)) return true;
335
+ }
336
+ return false;
337
+ }
338
+
339
+ export class Recorder {
340
+ private bridge: WorkerBridge;
341
+ private visitorId: string;
342
+ private preBuffer: eventWithTime[] = [];
343
+ private isRecording = false;
344
+ private stopRrweb: (() => void) | null = null;
345
+ private userId: string | null = null;
346
+ private userProperties: Record<string, string | number | boolean> = {};
347
+ private userPropertiesDirty = false;
348
+ private lastHref: string = '';
349
+ private lastEmittedFlagToken: string | null = null;
350
+ private flagCheckTimer: ReturnType<typeof setInterval> | null = null;
351
+ private origPushState: typeof history.pushState | null = null;
352
+ private origReplaceState: typeof history.replaceState | null = null;
353
+
354
+ // Form tracking state
355
+ private formStarted = new Set<string>();
356
+ private formStartTimestamps = new Map<string, number>();
357
+ private focusTimestamps = new Map<string, number>();
358
+
359
+ // Heatmap tracking state
360
+ private lastMouseMoveEmit = 0;
361
+
362
+ // Frustration signal: excessive scrolling
363
+ private scrollHistory: Array<{ y: number; t: number; dir: 'up' | 'down' }> = [];
364
+ private lastScrollDirection: 'up' | 'down' | null = null;
365
+ private lastScrollY = 0;
366
+
367
+ // Frustration signal: form field retries
368
+ private fieldFocusCounts = new Map<string, number[]>();
369
+
370
+ // Frustration signal: idle detection
371
+ private lastInteractionTime = 0;
372
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
373
+ private idleEmitted = false;
374
+ private static readonly IDLE_THRESHOLD_MS = 30_000;
375
+
376
+ // Error deduplication state
377
+ private lastErrorMessage = '';
378
+ private lastErrorTime = 0;
379
+
380
+ // Scroll depth tracking via IntersectionObserver sentinels
381
+ private static readonly SCROLL_DEPTH_THRESHOLDS = [25, 50, 75, 100];
382
+ private scrollDepthHits = new Set<number>();
383
+ private scrollSentinels: HTMLElement[] = [];
384
+ private scrollDepthObserver: IntersectionObserver | null = null;
385
+
386
+ // Delayed "settled" snapshot to capture fully-rendered page (after async content loads)
387
+ private settledSnapshotTimer: ReturnType<typeof setTimeout> | null = null;
388
+ private static readonly SETTLED_SNAPSHOT_DELAY_MS = 3_000;
389
+
390
+ // Tab visibility session lifecycle
391
+ private hiddenAt: number | null = null;
392
+ private static readonly VISIBILITY_GRACE_MS = 120_000; // 2 minutes
393
+ public endedByVisibility = false;
394
+ private isHidden = false;
395
+
396
+ private propertyId: string;
397
+ private privacyMode: 'default' | 'relaxed';
398
+ private excludePagePatterns: RegExp[];
399
+ private isPaused = false;
400
+
401
+ constructor(bridge: WorkerBridge, propertyId: string, visitorId: string, options?: { privacyMode?: 'default' | 'relaxed'; excludePages?: string[] }) {
402
+ this.bridge = bridge;
403
+ this.propertyId = propertyId;
404
+ this.visitorId = visitorId;
405
+ this.privacyMode = options?.privacyMode ?? 'default';
406
+ this.excludePagePatterns = (options?.excludePages ?? []).map(globToRegex).filter((r): r is RegExp => r !== null);
407
+
408
+ // Stop recording if the transport is killed (invalid API key, etc.)
409
+ this.bridge.onKilled(() => {
410
+ this.preBuffer = [];
411
+ this.isRecording = false;
412
+ if (this.stopRrweb) { this.stopRrweb(); this.stopRrweb = null; }
413
+ });
414
+ }
415
+
416
+ start(autoRecord: boolean): void {
417
+ try {
418
+ this.lastHref = window.location.href;
419
+
420
+ const initialPageExcluded = this.excludePagePatterns.length > 0 &&
421
+ matchesAnyPattern(window.location.pathname, this.excludePagePatterns);
422
+
423
+ // Set recording state BEFORE starting rrweb so the initial FullSnapshot
424
+ // goes to the bridge (not the preBuffer) when autoRecord is on.
425
+ if (autoRecord) {
426
+ this.isRecording = true;
427
+ this.bridge.postMetadata(this.collectMetadata());
428
+ }
429
+
430
+ // Always start rrweb — events go to either buffer or preBuffer.
431
+ // rrweb's record() emits its own Meta event (type 4) with href,
432
+ // so the initial page automatically appears in the pages list.
433
+ // Do NOT emit a second Meta event here — it would land after the
434
+ // FullSnapshot in the buffer, causing discardPriorSnapshots() to
435
+ // skip the FullSnapshot on backward seeks and break replay.
436
+ this.startRrweb();
437
+
438
+ // If the initial page is excluded, emit the placeholder event after rrweb
439
+ // has started (so the session has a valid FullSnapshot), then pause.
440
+ if (initialPageExcluded) {
441
+ this.emitCustomEvent('ss-page-excluded', { href: window.location.href });
442
+ this.bridge.flush();
443
+ this.isPaused = true;
444
+ }
445
+
446
+ // Track SPA navigations
447
+ this.patchHistoryMethods();
448
+ window.addEventListener('popstate', this.handleNavigation);
449
+
450
+ // Track user interactions
451
+ document.addEventListener('click', this.handleClick, true);
452
+ document.addEventListener('submit', this.handleFormSubmit, true);
453
+ document.addEventListener('focusin', this.handleFieldFocus, true);
454
+ document.addEventListener('focusout', this.handleFieldBlur, true);
455
+
456
+ // Heatmap tracking
457
+ document.addEventListener('click', this.handleHeatmapClick, true);
458
+ document.addEventListener('mousemove', this.handleHeatmapMouseMove, { capture: true, passive: true });
459
+ window.addEventListener('scroll', this.handleHeatmapScroll, { capture: true, passive: true });
460
+
461
+ // Scroll depth tracking via IntersectionObserver (replaces per-frame scroll checks)
462
+ this.setupScrollDepthObserver();
463
+
464
+ // Error tracking
465
+ window.addEventListener('error', this.handleWindowError);
466
+ window.addEventListener('unhandledrejection', this.handleUnhandledRejection);
467
+
468
+ // Idle detection
469
+ document.addEventListener('keydown', this.handleKeydown, true);
470
+ this.lastInteractionTime = Date.now();
471
+ this.resetIdleTimer();
472
+
473
+ document.addEventListener('visibilitychange', this.handleVisibilityChange);
474
+ window.addEventListener('beforeunload', this.handleBeforeUnload);
475
+
476
+ // Periodically check for flag evaluation tokens (cross-SDK registry lives on main thread)
477
+ this.flagCheckTimer = setInterval(() => this.checkFlagToken(), FLAG_CHECK_INTERVAL_MS);
478
+ } catch (e) {
479
+ // SDK must never break the host page
480
+ console.warn('SessionSight: failed to start recorder', e);
481
+ }
482
+ }
483
+
484
+ /** Start persisting events to the server. Call after init({ autoRecord: false }). */
485
+ beginRecording(options?: RecordOptions): void {
486
+ if (this.isRecording) return;
487
+
488
+ this.isRecording = true;
489
+
490
+ // Send metadata to the worker/bridge
491
+ this.bridge.postMetadata(this.collectMetadata());
492
+
493
+ // Drain pre-buffer: send kept events to the bridge
494
+ const preRecordSecs = Math.min(5, Math.max(0, options?.preRecordSecs || 0));
495
+ if (preRecordSecs > 0 && this.preBuffer.length > 0) {
496
+ const cutoff = Date.now() - preRecordSecs * 1000;
497
+ for (const e of this.preBuffer) {
498
+ if (e.timestamp >= cutoff) this.bridge.postEvent(e);
499
+ }
500
+ }
501
+ this.preBuffer = [];
502
+
503
+ // Trigger immediate flush so the first batch goes out
504
+ this.bridge.flush();
505
+ }
506
+
507
+ stop(): void {
508
+ if (this.settledSnapshotTimer) {
509
+ clearTimeout(this.settledSnapshotTimer);
510
+ this.settledSnapshotTimer = null;
511
+ }
512
+ if (this.flagCheckTimer) {
513
+ clearInterval(this.flagCheckTimer);
514
+ this.flagCheckTimer = null;
515
+ }
516
+ this.hiddenAt = null;
517
+ this.isHidden = false;
518
+ if (this.stopRrweb) {
519
+ this.stopRrweb();
520
+ this.stopRrweb = null;
521
+ }
522
+
523
+ this.unpatchHistoryMethods();
524
+ window.removeEventListener('popstate', this.handleNavigation);
525
+
526
+ document.removeEventListener('click', this.handleClick, true);
527
+ document.removeEventListener('submit', this.handleFormSubmit, true);
528
+ document.removeEventListener('focusin', this.handleFieldFocus, true);
529
+ document.removeEventListener('focusout', this.handleFieldBlur, true);
530
+
531
+ document.removeEventListener('click', this.handleHeatmapClick, true);
532
+ document.removeEventListener('mousemove', this.handleHeatmapMouseMove, { capture: true } as EventListenerOptions);
533
+ window.removeEventListener('scroll', this.handleHeatmapScroll, { capture: true } as EventListenerOptions);
534
+ this.teardownScrollDepthObserver();
535
+
536
+ window.removeEventListener('error', this.handleWindowError);
537
+ window.removeEventListener('unhandledrejection', this.handleUnhandledRejection);
538
+
539
+ document.removeEventListener('keydown', this.handleKeydown, true);
540
+ if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; }
541
+
542
+ document.removeEventListener('visibilitychange', this.handleVisibilityChange);
543
+ window.removeEventListener('beforeunload', this.handleBeforeUnload);
544
+
545
+ if (this.isRecording) {
546
+ this.bridge.flush();
547
+ }
548
+ this.isRecording = false;
549
+ this.bridge.destroy();
550
+ maskCacheClear();
551
+ }
552
+
553
+ getVisitorId(): string {
554
+ return this.visitorId;
555
+ }
556
+
557
+ getBridge(): WorkerBridge {
558
+ return this.bridge;
559
+ }
560
+
561
+ getPropertyId(): string {
562
+ return this.propertyId;
563
+ }
564
+
565
+ identify(userId: string, properties?: Record<string, string | number | boolean>): void {
566
+ this.userId = userId;
567
+ this.bridge.postIdentify(userId, properties);
568
+ if (properties) {
569
+ Object.assign(this.userProperties, properties);
570
+ this.userPropertiesDirty = true;
571
+ this.emitCustomEvent('set_user_properties', { ...properties });
572
+ }
573
+ }
574
+
575
+ /**
576
+ * Apply server-delivered privacy configuration. If settings differ from the
577
+ * current defaults, update the recorder and trigger a fresh FullSnapshot so
578
+ * replay uses the correct masking from this point forward.
579
+ */
580
+ applyPrivacyConfig(config: PrivacyConfig): void {
581
+ const modeChanged = config.privacyMode !== this.privacyMode;
582
+
583
+ // Update internal state from the server config
584
+ this.privacyMode = config.privacyMode;
585
+ this.excludePagePatterns = (config.excludePages ?? []).map(globToRegex).filter((r): r is RegExp => r !== null);
586
+
587
+ // Re-evaluate page exclusion with the new patterns
588
+ const shouldExclude = this.excludePagePatterns.length > 0 &&
589
+ matchesAnyPattern(window.location.pathname, this.excludePagePatterns);
590
+
591
+ if (shouldExclude && !this.isPaused) {
592
+ this.bridge.flush();
593
+ this.emitCustomEvent('ss-page-excluded', { href: window.location.href });
594
+ this.bridge.flush();
595
+ this.isPaused = true;
596
+ } else if (!shouldExclude && this.isPaused) {
597
+ this.isPaused = false;
598
+ }
599
+
600
+ // If the privacy mode changed, restart rrweb for a new FullSnapshot with correct masking
601
+ if (modeChanged && !this.isPaused) {
602
+ if (this.stopRrweb) this.stopRrweb();
603
+ this.startRrweb();
604
+ }
605
+ }
606
+
607
+ // ── rrweb lifecycle ────────────────────────────────────────────────
608
+
609
+ /**
610
+ * Stamp fixed dimensions onto data-ss-exclude elements so that when rrweb
611
+ * replaces them with empty placeholders (via blockSelector), the placeholders
612
+ * preserve the original layout space.
613
+ */
614
+ private stampExcludedDimensions(): void {
615
+ const excluded = document.querySelectorAll('[data-ss-exclude]');
616
+ for (const el of excluded) {
617
+ const htmlEl = el as HTMLElement;
618
+ // Skip if already stamped
619
+ if (htmlEl.style.getPropertyValue('--ss-stamped')) continue;
620
+ const rect = htmlEl.getBoundingClientRect();
621
+ htmlEl.style.setProperty('width', rect.width + 'px', 'important');
622
+ htmlEl.style.setProperty('height', rect.height + 'px', 'important');
623
+ htmlEl.style.setProperty('min-height', rect.height + 'px', 'important');
624
+ htmlEl.style.setProperty('box-sizing', 'border-box', 'important');
625
+ htmlEl.style.setProperty('--ss-stamped', '1');
626
+ }
627
+ }
628
+
629
+ private startRrweb(): void {
630
+ try {
631
+ const privacyMode = this.privacyMode;
632
+
633
+ // Stamp dimensions on excluded elements before rrweb starts.
634
+ // rrweb's blockSelector replaces these with empty <div> placeholders,
635
+ // but the inline width/height styles are preserved on the placeholder,
636
+ // so the layout space is maintained.
637
+ this.stampExcludedDimensions();
638
+
639
+ this.stopRrweb = record({
640
+ emit: (event: eventWithTime) => {
641
+ try {
642
+ // Scramble placeholder attributes in serialized nodes before buffering
643
+ maskEventPlaceholders(event, privacyMode);
644
+ this.pushEvent(event);
645
+ } catch (e) {
646
+ console.warn('SessionSight: error in rrweb emit callback', e);
647
+ }
648
+ },
649
+ // Take a full DOM snapshot every 30s so the replayer can seek without
650
+ // replaying every mutation from the start of the session.
651
+ checkoutEveryNms: 30_000,
652
+ // Default is 100ms (10fps) which makes scroll replay choppy.
653
+ sampling: { scroll: 50 },
654
+ inlineStylesheet: true,
655
+
656
+ // Replace excluded elements with empty placeholders (preserves inline styles)
657
+ blockSelector: '[data-ss-exclude]',
658
+
659
+ // Mask all input values, with custom logic respecting data-ss attributes
660
+ maskAllInputs: true,
661
+ maskInputFn: (text: string, element: HTMLElement): string => {
662
+ try {
663
+ return applyMasking(text, element, privacyMode);
664
+ } catch (e) {
665
+ console.warn('SessionSight: error in maskInputFn', e);
666
+ return text;
667
+ }
668
+ },
669
+
670
+ // Fire maskTextFn for every text node.
671
+ maskTextSelector: '*',
672
+ maskTextFn: (text: string, element: HTMLElement | null): string => {
673
+ try {
674
+ return applyMasking(text, element, privacyMode);
675
+ } catch (e) {
676
+ console.warn('SessionSight: error in maskTextFn', e);
677
+ return text;
678
+ }
679
+ },
680
+ }) ?? null;
681
+
682
+ this.scheduleSettledSnapshot();
683
+ } catch (e) {
684
+ console.warn('SessionSight: failed to start rrweb', e);
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Schedule a delayed FullSnapshot to capture the page after async content
690
+ * has loaded (lazy images, client-rendered components, etc.). This gives
691
+ * the heatmap a much better baseline than the immediate rrweb snapshot.
692
+ */
693
+ private scheduleSettledSnapshot(): void {
694
+ if (this.settledSnapshotTimer) clearTimeout(this.settledSnapshotTimer);
695
+ this.settledSnapshotTimer = setTimeout(() => {
696
+ this.settledSnapshotTimer = null;
697
+ if (this.stopRrweb && !this.isPaused) record.takeFullSnapshot();
698
+ }, Recorder.SETTLED_SNAPSHOT_DELAY_MS);
699
+ }
700
+
701
+ /** Route an event to the bridge or pre-buffer */
702
+ private pushEvent(event: eventWithTime): void {
703
+ // When paused due to page exclusion, drop all events
704
+ if (this.isPaused) return;
705
+
706
+ // Drop events while tab is hidden so background mutations don't inflate
707
+ // lastEventAt on the server. The session will either resume or end when
708
+ // the tab becomes visible again.
709
+ if (this.isHidden) return;
710
+
711
+ if (this.isRecording) {
712
+ this.bridge.postEvent(event);
713
+ // Flush FullSnapshot immediately so replay data survives early bounces
714
+ if (event.type === FULL_SNAPSHOT_EVENT_TYPE) {
715
+ this.bridge.flush();
716
+ }
717
+ } else {
718
+ // Pre-buffer: keep last 5 seconds
719
+ this.preBuffer.push(event);
720
+ this.trimPreBuffer();
721
+ }
722
+ }
723
+
724
+ /** Trim pre-buffer to keep only the last 5 seconds of events */
725
+ private trimPreBuffer(): void {
726
+ if (this.preBuffer.length === 0) return;
727
+ const cutoff = Date.now() - PRE_BUFFER_MAX_MS;
728
+ // Find first event that's within the window
729
+ let firstValid = 0;
730
+ while (firstValid < this.preBuffer.length && this.preBuffer[firstValid]!.timestamp < cutoff) {
731
+ firstValid++;
732
+ }
733
+ if (firstValid > 0) {
734
+ this.preBuffer.splice(0, firstValid);
735
+ }
736
+ }
737
+
738
+ // ── Custom event helpers ───────────────────────────────────────────
739
+
740
+ private emitCustomEvent(tag: string, payload: Record<string, any>): void {
741
+ const event: eventWithTime = {
742
+ type: CUSTOM_EVENT_TYPE,
743
+ data: { tag, payload },
744
+ timestamp: Date.now(),
745
+ };
746
+ this.pushEvent(event);
747
+ }
748
+
749
+ // ── Form identification ────────────────────────────────────────────
750
+
751
+ private static readonly INPUT_SELECTOR = 'input:not([type="hidden"]):not([type="submit"]):not([type="button"]), textarea, select';
752
+
753
+ /** Find the nearest grouping container: <form>, [data-ss-form], or null (page-level). */
754
+ private getFormContainer(target: HTMLElement): HTMLElement | null {
755
+ return target.closest('form') || target.closest('[data-ss-form]');
756
+ }
757
+
758
+ private getFormInfo(container: HTMLElement | null): { formId: string; formName: string } {
759
+ const page = window.location.pathname;
760
+
761
+ if (!container) {
762
+ return { formId: `${page}:_page`, formName: page };
763
+ }
764
+
765
+ if (container.tagName === 'FORM') {
766
+ const allForms = Array.from(document.querySelectorAll('form'));
767
+ const index = allForms.indexOf(container as HTMLFormElement);
768
+ const indexStr = index >= 0 ? String(index) : '0';
769
+ const formId = `${page}:${container.id || indexStr}`;
770
+ const dataName = container.getAttribute('data-ss-form');
771
+ const formName = dataName || container.id || `Form ${index + 1}`;
772
+ return { formId, formName };
773
+ }
774
+
775
+ // [data-ss-form] container
776
+ const dataName = container.getAttribute('data-ss-form')!;
777
+ const formId = `${page}:${container.id || dataName}`;
778
+ return { formId, formName: dataName };
779
+ }
780
+
781
+ private getFieldInfo(el: HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement, container: HTMLElement | null) {
782
+ const scope = container || document;
783
+ const inputs = Array.from(scope.querySelectorAll(Recorder.INPUT_SELECTOR));
784
+ const index = inputs.indexOf(el);
785
+ const fieldId = el.id || el.name || `field-${index}`;
786
+ const fieldName = el.name || el.id || `field-${index}`;
787
+ const fieldType = el.tagName === 'SELECT' ? 'select' : el.tagName === 'TEXTAREA' ? 'textarea' : (el as HTMLInputElement).type || 'text';
788
+
789
+ let fieldLabel = '';
790
+ if (el.id) {
791
+ const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
792
+ if (label) fieldLabel = (label.textContent?.trim() || '').slice(0, 50);
793
+ }
794
+ if (!fieldLabel) {
795
+ const parent = el.closest('label');
796
+ if (parent) {
797
+ const clone = parent.cloneNode(true) as HTMLElement;
798
+ clone.querySelectorAll('input, textarea, select').forEach(c => c.remove());
799
+ fieldLabel = (clone.textContent?.trim() || '').slice(0, 50);
800
+ }
801
+ }
802
+ if (!fieldLabel) {
803
+ fieldLabel = el.getAttribute('placeholder')?.slice(0, 50) || fieldName;
804
+ }
805
+
806
+ return { fieldId, fieldName, fieldType, fieldLabel };
807
+ }
808
+
809
+ // ── Field focus/blur tracking ──────────────────────────────────────
810
+
811
+ private handleFieldFocus = (e: FocusEvent): void => {
812
+ try {
813
+ const target = e.target as HTMLElement;
814
+ if (!target) return;
815
+ if (!target.matches(Recorder.INPUT_SELECTOR)) return;
816
+
817
+ const container = this.getFormContainer(target);
818
+ const { formId, formName } = this.getFormInfo(container);
819
+ const field = this.getFieldInfo(target as HTMLInputElement, container);
820
+ const page = window.location.pathname;
821
+
822
+ if (!this.formStarted.has(formId)) {
823
+ this.formStarted.add(formId);
824
+ this.formStartTimestamps.set(formId, Date.now());
825
+ const scope = container || document;
826
+ const inputs = scope.querySelectorAll(Recorder.INPUT_SELECTOR);
827
+ this.emitCustomEvent('form_start', { formId, formName, page, fieldCount: inputs.length });
828
+ }
829
+
830
+ const focusKey = `${formId}:${field.fieldId}`;
831
+ this.focusTimestamps.set(focusKey, Date.now());
832
+ this.emitCustomEvent('field_focus', { formId, formName, page, ...field });
833
+
834
+ // Frustration signal: detect repeated field focus (form field retries)
835
+ const now = Date.now();
836
+ const focusTimes = this.fieldFocusCounts.get(focusKey) || [];
837
+ focusTimes.push(now);
838
+ // Keep only entries within 30 seconds
839
+ const retryCutoff = now - 30_000;
840
+ const recent = focusTimes.filter(t => t >= retryCutoff);
841
+ this.fieldFocusCounts.set(focusKey, recent);
842
+ if (recent.length >= 3) {
843
+ this.emitCustomEvent('form_field_retry', { formName, fieldName: field.fieldName, page, retries: recent.length });
844
+ this.fieldFocusCounts.set(focusKey, []);
845
+ }
846
+
847
+ // Reset idle timer on interaction
848
+ this.resetIdleTimer();
849
+ } catch (e2) {
850
+ console.warn('SessionSight: error in field focus handler', e2);
851
+ }
852
+ };
853
+
854
+ private handleFieldBlur = (e: FocusEvent): void => {
855
+ try {
856
+ const target = e.target as HTMLElement;
857
+ if (!target) return;
858
+ if (!target.matches(Recorder.INPUT_SELECTOR)) return;
859
+
860
+ const container = this.getFormContainer(target);
861
+ const { formId, formName } = this.getFormInfo(container);
862
+ const field = this.getFieldInfo(target as HTMLInputElement, container);
863
+ const page = window.location.pathname;
864
+
865
+ const focusKey = `${formId}:${field.fieldId}`;
866
+ const focusTime = this.focusTimestamps.get(focusKey);
867
+ const timeSpent = focusTime ? Date.now() - focusTime : 0;
868
+ this.focusTimestamps.delete(focusKey);
869
+
870
+ const el = target as HTMLInputElement;
871
+ let hasValue = false;
872
+ if (el.type === 'checkbox' || el.type === 'radio') hasValue = el.checked;
873
+ else hasValue = (el.value || '').trim().length > 0;
874
+
875
+ this.emitCustomEvent('field_blur', { formId, formName, page, ...field, timeSpent, hasValue });
876
+ } catch (e2) {
877
+ console.warn('SessionSight: error in field blur handler', e2);
878
+ }
879
+ };
880
+
881
+ // ── Click tracking ─────────────────────────────────────────────────
882
+
883
+ private handleClick = (e: MouseEvent): void => {
884
+ try {
885
+ const target = e.target as HTMLElement;
886
+ if (!target) return;
887
+ const el = target.closest('button, a, [role="button"], input[type="submit"], input[type="button"]') as HTMLElement | null;
888
+ if (!el) return;
889
+ const label = this.getElementLabel(el) || el.tagName.toLowerCase();
890
+
891
+ let href: string | undefined;
892
+ try {
893
+ const rawHref = (el as HTMLAnchorElement).href;
894
+ if (rawHref) href = new URL(rawHref, window.location.origin).pathname;
895
+ } catch {
896
+ // Malformed href, skip it
897
+ }
898
+
899
+ this.emitCustomEvent('click', {
900
+ label,
901
+ tag: el.tagName.toLowerCase(),
902
+ page: window.location.pathname,
903
+ href,
904
+ });
905
+ } catch (e) {
906
+ console.warn('SessionSight: error in click handler', e);
907
+ }
908
+ };
909
+
910
+ private getElementLabel(el: HTMLElement): string | null {
911
+ const ariaLabel = el.getAttribute('aria-label');
912
+ if (ariaLabel) return ariaLabel.slice(0, 80);
913
+ const text = el.textContent?.trim();
914
+ if (text && text.length <= 80) return text;
915
+ if (text) return text.slice(0, 77) + '...';
916
+ const title = el.getAttribute('title');
917
+ if (title) return title.slice(0, 80);
918
+ if (el.tagName === 'INPUT') {
919
+ const val = (el as HTMLInputElement).value;
920
+ if (val) return val.slice(0, 80);
921
+ }
922
+ return null;
923
+ }
924
+
925
+ // ── Form submit tracking ───────────────────────────────────────────
926
+
927
+ private handleFormSubmit = (e: Event): void => {
928
+ try {
929
+ const form = e.target as HTMLFormElement;
930
+ if (!form || form.tagName !== 'FORM') return;
931
+ const { formId, formName } = this.getFormInfo(form);
932
+ const page = window.location.pathname;
933
+ const inputs = form.querySelectorAll(Recorder.INPUT_SELECTOR);
934
+ const filledFields = Array.from(inputs).filter((input) => {
935
+ const el = input as HTMLInputElement;
936
+ if (el.type === 'checkbox' || el.type === 'radio') return el.checked;
937
+ return el.value.trim().length > 0;
938
+ }).length;
939
+ const startTime = this.formStartTimestamps.get(formId);
940
+ const timeToComplete = startTime ? Date.now() - startTime : 0;
941
+ this.emitCustomEvent('form_submit', { formId, formName, page, totalFields: inputs.length, filledFields, timeToComplete });
942
+ } catch (e2) {
943
+ console.warn('SessionSight: error in form submit handler', e2);
944
+ }
945
+ };
946
+
947
+ // ── Heatmap tracking ───────────────────────────────────────────────
948
+
949
+ private static readonly INTERACTIVE_SELECTOR = [
950
+ 'a', 'button', 'select', 'textarea',
951
+ 'input', 'label', 'summary', 'details',
952
+ '[role="button"]', '[role="link"]', '[role="tab"]',
953
+ '[role="radio"]', '[role="checkbox"]', '[role="option"]',
954
+ '[role="menuitem"]', '[role="switch"]', '[role="slider"]',
955
+ '[tabindex]', '[onclick]', '[data-action]',
956
+ ].join(', ');
957
+
958
+ private static readonly DEAD_CLICK_DEFER_MS = 400;
959
+
960
+ private handleHeatmapClick = (e: MouseEvent): void => {
961
+ try {
962
+ const target = e.target as HTMLElement | null;
963
+ // Walk up to find the nearest interactive ancestor
964
+ const interactive = target?.closest(Recorder.INTERACTIVE_SELECTOR) as HTMLElement | null;
965
+ const el = interactive || target;
966
+ const tagName = el?.tagName?.toLowerCase() || '';
967
+ const text = (el?.textContent || '').trim().slice(0, 100);
968
+ const href = (el as HTMLAnchorElement)?.href || '';
969
+
970
+ const clickData = {
971
+ x: Math.round((e.clientX / window.innerWidth) * 10000) / 100,
972
+ y: Math.round(((e.clientY + window.scrollY) / document.documentElement.scrollHeight) * 10000) / 100,
973
+ documentHeight: document.documentElement.scrollHeight,
974
+ viewportX: e.clientX,
975
+ viewportY: e.clientY,
976
+ page: window.location.pathname,
977
+ elementTag: tagName,
978
+ elementText: text,
979
+ elementHref: href,
980
+ isInteractive: true,
981
+ };
982
+
983
+ // If the element is clearly interactive, emit immediately
984
+ if (interactive) {
985
+ this.emitCustomEvent('mouse_click', clickData);
986
+ } else {
987
+ // Defer: watch for DOM mutations or URL changes that indicate the click did something
988
+ this.deferDeadClickCheck(clickData);
989
+ }
990
+
991
+ // Reset idle timer on click
992
+ this.resetIdleTimer();
993
+ } catch (e2) {
994
+ console.warn('SessionSight: error in heatmap click handler', e2);
995
+ }
996
+ };
997
+
998
+ private deferDeadClickCheck(clickData: Record<string, any>): void {
999
+ const startUrl = window.location.href;
1000
+ let sawMutation = false;
1001
+
1002
+ // document.body can be null in edge cases (e.g. early script execution)
1003
+ const observeTarget = document.body || document.documentElement;
1004
+ if (!observeTarget) {
1005
+ clickData.isInteractive = false;
1006
+ this.emitCustomEvent('mouse_click', clickData);
1007
+ return;
1008
+ }
1009
+
1010
+ const observer = new MutationObserver(() => { sawMutation = true; });
1011
+ try {
1012
+ observer.observe(observeTarget, { childList: true, subtree: true });
1013
+ } catch (e) {
1014
+ console.warn('SessionSight: error observing mutations', e);
1015
+ clickData.isInteractive = false;
1016
+ this.emitCustomEvent('mouse_click', clickData);
1017
+ return;
1018
+ }
1019
+
1020
+ setTimeout(() => {
1021
+ observer.disconnect();
1022
+ const urlChanged = window.location.href !== startUrl;
1023
+ clickData.isInteractive = sawMutation || urlChanged;
1024
+ this.emitCustomEvent('mouse_click', clickData);
1025
+ }, Recorder.DEAD_CLICK_DEFER_MS);
1026
+ }
1027
+
1028
+ private handleHeatmapMouseMove = (e: MouseEvent): void => {
1029
+ try {
1030
+ const now = Date.now();
1031
+ // Reset idle timer on mouse move (cheap check, no timeout reset every move)
1032
+ if (now - this.lastInteractionTime > 5000) this.resetIdleTimer();
1033
+ if (now - this.lastMouseMoveEmit < 500) return;
1034
+ this.lastMouseMoveEmit = now;
1035
+ this.emitCustomEvent('mouse_move', {
1036
+ x: Math.round((e.clientX / window.innerWidth) * 10000) / 100,
1037
+ y: Math.round(((e.clientY + window.scrollY) / document.documentElement.scrollHeight) * 10000) / 100,
1038
+ documentHeight: document.documentElement.scrollHeight,
1039
+ page: window.location.pathname,
1040
+ });
1041
+ } catch (e2) {
1042
+ console.warn('SessionSight: error in mousemove handler', e2);
1043
+ }
1044
+ };
1045
+
1046
+ private handleHeatmapScroll = (): void => {
1047
+ try {
1048
+ const now = Date.now();
1049
+ const scrollY = window.scrollY;
1050
+
1051
+ // Detect excessive scrolling: rapid direction changes
1052
+ if (this.lastScrollY !== 0) {
1053
+ const dir: 'up' | 'down' = scrollY > this.lastScrollY ? 'down' : 'up';
1054
+ if (dir !== this.lastScrollDirection && this.lastScrollDirection !== null) {
1055
+ this.scrollHistory.push({ y: scrollY, t: now, dir });
1056
+ // Keep only entries within 3 seconds
1057
+ const cutoff = now - 3000;
1058
+ while (this.scrollHistory.length > 0 && this.scrollHistory[0]!.t < cutoff) {
1059
+ this.scrollHistory.shift();
1060
+ }
1061
+ // 4+ direction reversals in 3s = excessive scrolling
1062
+ if (this.scrollHistory.length >= 4) {
1063
+ this.emitCustomEvent('excessive_scroll', { page: window.location.pathname, count: this.scrollHistory.length });
1064
+ this.scrollHistory = [];
1065
+ }
1066
+ }
1067
+ this.lastScrollDirection = dir;
1068
+ }
1069
+ this.lastScrollY = scrollY;
1070
+
1071
+ // Reset idle timer on scroll
1072
+ this.resetIdleTimer();
1073
+ } catch (e) {
1074
+ console.warn('SessionSight: error in scroll handler', e);
1075
+ }
1076
+ };
1077
+
1078
+ // ── Scroll depth via IntersectionObserver ──────────────────────────
1079
+
1080
+ private setupScrollDepthObserver(): void {
1081
+ this.teardownScrollDepthObserver();
1082
+
1083
+ if (typeof IntersectionObserver === 'undefined') return;
1084
+
1085
+ this.scrollDepthObserver = new IntersectionObserver((entries) => {
1086
+ try {
1087
+ for (const entry of entries) {
1088
+ if (!entry.isIntersecting) continue;
1089
+ const threshold = Number((entry.target as HTMLElement).dataset.ssDepth);
1090
+ if (isNaN(threshold) || this.scrollDepthHits.has(threshold)) continue;
1091
+
1092
+ this.scrollDepthHits.add(threshold);
1093
+ const scrollY = window.scrollY;
1094
+ const maxScrollY = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
1095
+ this.emitCustomEvent('scroll_depth', {
1096
+ scrollY,
1097
+ maxScrollY,
1098
+ scrollPercent: threshold,
1099
+ viewportHeight: window.innerHeight,
1100
+ page: window.location.pathname,
1101
+ });
1102
+
1103
+ // Take a FullSnapshot at 25/50/75% to capture reveal-on-scroll content
1104
+ if (threshold < 100 && this.stopRrweb && !this.isPaused) {
1105
+ record.takeFullSnapshot();
1106
+ }
1107
+
1108
+ // Unobserve this sentinel since it already fired
1109
+ this.scrollDepthObserver?.unobserve(entry.target);
1110
+ }
1111
+ } catch (e) {
1112
+ console.warn('SessionSight: error in scroll depth observer', e);
1113
+ }
1114
+ }, { threshold: 0 });
1115
+
1116
+ this.placeSentinels();
1117
+ }
1118
+
1119
+ private placeSentinels(): void {
1120
+ // Remove existing sentinels before placing new ones
1121
+ for (const el of this.scrollSentinels) el.remove();
1122
+ this.scrollSentinels = [];
1123
+
1124
+ const docHeight = document.documentElement.scrollHeight;
1125
+ for (const pct of Recorder.SCROLL_DEPTH_THRESHOLDS) {
1126
+ if (this.scrollDepthHits.has(pct)) continue;
1127
+ const sentinel = document.createElement('div');
1128
+ sentinel.dataset.ssDepth = String(pct);
1129
+ sentinel.setAttribute('aria-hidden', 'true');
1130
+ sentinel.style.cssText = 'position:absolute;left:0;width:1px;height:1px;pointer-events:none;opacity:0;z-index:-1;';
1131
+ // Place at the % offset from top. 100% = bottom of the document.
1132
+ sentinel.style.top = `${Math.min(docHeight - 1, (pct / 100) * docHeight)}px`;
1133
+ document.documentElement.appendChild(sentinel);
1134
+ this.scrollSentinels.push(sentinel);
1135
+ this.scrollDepthObserver?.observe(sentinel);
1136
+ }
1137
+ }
1138
+
1139
+ private teardownScrollDepthObserver(): void {
1140
+ if (this.scrollDepthObserver) {
1141
+ this.scrollDepthObserver.disconnect();
1142
+ this.scrollDepthObserver = null;
1143
+ }
1144
+ for (const el of this.scrollSentinels) el.remove();
1145
+ this.scrollSentinels = [];
1146
+ }
1147
+
1148
+ // ── Error tracking ───────────────────────────────────────────────
1149
+
1150
+ private sanitizePii(str: string): string {
1151
+ return str
1152
+ // Strip email addresses
1153
+ .replace(/[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}/g, '[email]')
1154
+ // Strip phone numbers (various formats)
1155
+ .replace(/(\+?\d{1,3}[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}/g, '[phone]')
1156
+ // Strip query strings from URLs (may contain tokens/PII)
1157
+ .replace(/(https?:\/\/[^\s?#]+)\?[^\s)"]*/g, '$1?[redacted]');
1158
+ }
1159
+
1160
+ private stripQueryString(url: string): string {
1161
+ try {
1162
+ const u = new URL(url);
1163
+ return u.origin + u.pathname;
1164
+ } catch {
1165
+ return url.replace(/\?.*$/, '');
1166
+ }
1167
+ }
1168
+
1169
+ private emitErrorEvent(data: {
1170
+ message: string;
1171
+ stack: string;
1172
+ source: string;
1173
+ lineno: number;
1174
+ colno: number;
1175
+ type: 'uncaught' | 'unhandled_rejection';
1176
+ }): void {
1177
+ const now = Date.now();
1178
+ if (data.message === this.lastErrorMessage && now - this.lastErrorTime < 1000) return;
1179
+ this.lastErrorMessage = data.message;
1180
+ this.lastErrorTime = now;
1181
+
1182
+ this.emitCustomEvent('error', {
1183
+ message: this.sanitizePii(data.message),
1184
+ stack: this.sanitizePii(data.stack),
1185
+ source: this.stripQueryString(data.source),
1186
+ lineno: data.lineno,
1187
+ colno: data.colno,
1188
+ type: data.type,
1189
+ page: window.location.pathname,
1190
+ });
1191
+ }
1192
+
1193
+ private handleWindowError = (e: ErrorEvent): void => {
1194
+ this.emitErrorEvent({
1195
+ message: e.message || 'Unknown error',
1196
+ stack: (e.error?.stack || '').slice(0, 4000),
1197
+ source: e.filename || '',
1198
+ lineno: e.lineno || 0,
1199
+ colno: e.colno || 0,
1200
+ type: 'uncaught',
1201
+ });
1202
+ };
1203
+
1204
+ private handleUnhandledRejection = (e: PromiseRejectionEvent): void => {
1205
+ const reason = e.reason;
1206
+ const message = reason instanceof Error ? reason.message : String(reason || 'Unhandled rejection');
1207
+ const stack = reason instanceof Error ? (reason.stack || '').slice(0, 4000) : '';
1208
+ this.emitErrorEvent({
1209
+ message,
1210
+ stack,
1211
+ source: '',
1212
+ lineno: 0,
1213
+ colno: 0,
1214
+ type: 'unhandled_rejection',
1215
+ });
1216
+ };
1217
+
1218
+ // ── Idle detection ────────────────────────────────────────────────
1219
+
1220
+ private resetIdleTimer(): void {
1221
+ this.lastInteractionTime = Date.now();
1222
+ this.idleEmitted = false;
1223
+ if (this.idleTimer) {
1224
+ clearTimeout(this.idleTimer);
1225
+ }
1226
+ this.idleTimer = setTimeout(() => this.checkIdle(), Recorder.IDLE_THRESHOLD_MS);
1227
+ }
1228
+
1229
+ private checkIdle(): void {
1230
+ if (this.idleEmitted) return;
1231
+ if (document.visibilityState !== 'visible') return;
1232
+ const elapsed = Date.now() - this.lastInteractionTime;
1233
+ if (elapsed >= Recorder.IDLE_THRESHOLD_MS) {
1234
+ this.idleEmitted = true;
1235
+ this.emitCustomEvent('idle_detected', { duration: elapsed, page: window.location.pathname });
1236
+ }
1237
+ }
1238
+
1239
+ private handleKeydown = (): void => {
1240
+ this.resetIdleTimer();
1241
+ };
1242
+
1243
+ // ── SPA navigation tracking ────────────────────────────────────────
1244
+
1245
+ private patchHistoryMethods(): void {
1246
+ const nativePushState = History.prototype.pushState;
1247
+ const nativeReplaceState = History.prototype.replaceState;
1248
+ this.origPushState = history.pushState.bind(history);
1249
+ this.origReplaceState = history.replaceState.bind(history);
1250
+ const self = this;
1251
+ history.pushState = function (...args: Parameters<typeof history.pushState>) {
1252
+ // Use saved original if available, fall back to native prototype method.
1253
+ // Another library may hold a reference to this patched function after we
1254
+ // unpatch (setting origPushState to null), so we must never throw.
1255
+ const fn = self.origPushState || nativePushState.bind(history);
1256
+ fn(...args);
1257
+ try { self.handleNavigation(); } catch (e) { console.warn('SessionSight: error in pushState handler', e); }
1258
+ };
1259
+ history.replaceState = function (...args: Parameters<typeof history.replaceState>) {
1260
+ const fn = self.origReplaceState || nativeReplaceState.bind(history);
1261
+ fn(...args);
1262
+ try { self.handleNavigation(); } catch (e) { console.warn('SessionSight: error in replaceState handler', e); }
1263
+ };
1264
+ }
1265
+
1266
+ private unpatchHistoryMethods(): void {
1267
+ if (this.origPushState) { history.pushState = this.origPushState; this.origPushState = null; }
1268
+ if (this.origReplaceState) { history.replaceState = this.origReplaceState; this.origReplaceState = null; }
1269
+ }
1270
+
1271
+ private handleNavigation = (): void => {
1272
+ try {
1273
+ const currentHref = window.location.href;
1274
+ if (currentHref === this.lastHref) return;
1275
+ this.lastHref = currentHref;
1276
+
1277
+ // Check if the new page should be excluded
1278
+ const shouldExclude = this.excludePagePatterns.length > 0 &&
1279
+ matchesAnyPattern(window.location.pathname, this.excludePagePatterns);
1280
+
1281
+ if (shouldExclude && !this.isPaused) {
1282
+ // Entering an excluded page: flush remaining events, emit placeholder, then pause
1283
+ this.bridge.flush();
1284
+ const metaEvent: eventWithTime = {
1285
+ type: META_EVENT_TYPE,
1286
+ data: { href: currentHref, width: window.innerWidth, height: window.innerHeight },
1287
+ timestamp: Date.now(),
1288
+ };
1289
+ this.pushEvent(metaEvent);
1290
+ this.emitCustomEvent('ss-page-excluded', { href: currentHref });
1291
+ this.bridge.flush();
1292
+ this.isPaused = true;
1293
+ return;
1294
+ }
1295
+
1296
+ if (!shouldExclude && this.isPaused) {
1297
+ // Leaving an excluded page: resume and take a fresh FullSnapshot
1298
+ this.isPaused = false;
1299
+ }
1300
+
1301
+ // Flush all buffered events from the previous page before doing anything else.
1302
+ // Without this, SPA navigations can cause in-flight fetches to be aborted,
1303
+ // losing the entire previous page's recording.
1304
+ this.bridge.flush();
1305
+
1306
+ // Previous page's text is now irrelevant, free the memory
1307
+ maskCacheClear();
1308
+
1309
+ const metaEvent: eventWithTime = {
1310
+ type: META_EVENT_TYPE,
1311
+ data: { href: currentHref, width: window.innerWidth, height: window.innerHeight },
1312
+ timestamp: Date.now(),
1313
+ };
1314
+ this.pushEvent(metaEvent);
1315
+
1316
+ // Cancel any pending settled snapshot before stopping rrweb, so it
1317
+ // cannot fire during the 100ms gap before startRrweb() re-initialises.
1318
+ if (this.settledSnapshotTimer) {
1319
+ clearTimeout(this.settledSnapshotTimer);
1320
+ this.settledSnapshotTimer = null;
1321
+ }
1322
+ // Restart rrweb for a fresh FullSnapshot of the new page.
1323
+ // Null out stopRrweb so guards (scheduleSettledSnapshot, scroll handler)
1324
+ // correctly skip takeFullSnapshot while recording is stopped.
1325
+ if (this.stopRrweb) {
1326
+ this.stopRrweb();
1327
+ this.stopRrweb = null;
1328
+ }
1329
+ // Reset scroll depth tracking for the new page
1330
+ this.scrollDepthHits.clear();
1331
+ setTimeout(() => {
1332
+ this.startRrweb();
1333
+ this.setupScrollDepthObserver();
1334
+ }, 100);
1335
+ } catch (e) {
1336
+ console.warn('SessionSight: error in navigation handler', e);
1337
+ }
1338
+ };
1339
+
1340
+ // ── Flag token check ────────────────────────────────────────────
1341
+
1342
+ private checkFlagToken(): void {
1343
+ try {
1344
+ const flagToken = getRegistryValue<string>('flagEvaluationToken');
1345
+ if (flagToken && flagToken !== this.lastEmittedFlagToken) {
1346
+ this.lastEmittedFlagToken = flagToken;
1347
+ this.emitCustomEvent('flag_evaluation', { token: flagToken });
1348
+ }
1349
+ } catch (e) {
1350
+ console.warn('SessionSight: error checking flag token', e);
1351
+ }
1352
+ }
1353
+
1354
+ private collectMetadata(): SessionMetadata {
1355
+ return {
1356
+ url: window.location.href,
1357
+ referrer: document.referrer || '',
1358
+ userAgent: navigator.userAgent,
1359
+ screenWidth: window.innerWidth,
1360
+ screenHeight: window.innerHeight,
1361
+ language: navigator.language,
1362
+ };
1363
+ }
1364
+
1365
+ private handleVisibilityChange = (): void => {
1366
+ try {
1367
+ if (document.visibilityState === 'hidden') {
1368
+ this.bridge.sendBeacon();
1369
+ this.hiddenAt = Date.now();
1370
+ this.isHidden = true;
1371
+ } else {
1372
+ this.isHidden = false;
1373
+ // Check elapsed time since the tab was hidden. Browsers throttle/freeze
1374
+ // setTimeout in background tabs, so a timestamp comparison is reliable
1375
+ // where a timer is not.
1376
+ if (this.hiddenAt && (Date.now() - this.hiddenAt) >= Recorder.VISIBILITY_GRACE_MS) {
1377
+ this.hiddenAt = null;
1378
+ this.endedByVisibility = true;
1379
+ this.stop();
1380
+ } else {
1381
+ this.hiddenAt = null;
1382
+ }
1383
+ }
1384
+ } catch (e) {
1385
+ console.warn('SessionSight: error in visibility handler', e);
1386
+ }
1387
+ };
1388
+
1389
+ private handleBeforeUnload = (): void => {
1390
+ try {
1391
+ this.bridge.sendBeacon();
1392
+ } catch (e) {
1393
+ console.warn('SessionSight: error in beforeunload handler', e);
1394
+ }
1395
+ };
1396
+ }