@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.
- package/LICENSE +21 -0
- package/README.md +287 -0
- package/build.ts +47 -0
- package/package.json +54 -0
- package/src/_worker-bundle.d.ts +3 -0
- package/src/iife.ts +3 -0
- package/src/index.ts +240 -0
- package/src/recorder.ts +1396 -0
- package/src/transport.ts +241 -0
- package/src/types.ts +56 -0
- package/src/worker-bridge.ts +315 -0
- package/src/worker-inline.ts +23 -0
- package/src/worker.ts +302 -0
- package/tsconfig.json +13 -0
package/src/recorder.ts
ADDED
|
@@ -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
|
+
}
|