@affogatosoftware/recorder 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/dist/browser/recorder.es.js +6136 -0
- package/dist/browser/recorder.iife.js +23 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.js +11 -0
- package/dist/recorder/eventRecorder.d.ts +48 -0
- package/dist/recorder/eventRecorder.js +399 -0
- package/dist/recorder/recorder.d.ts +49 -0
- package/dist/recorder/recorder.js +112 -0
- package/dist/recorder/sessionRecorder.d.ts +19 -0
- package/dist/recorder/sessionRecorder.js +104 -0
- package/dist/requests.d.ts +7 -0
- package/dist/requests.js +13 -0
- package/dist/utils.d.ts +4 -0
- package/dist/utils.js +16 -0
- package/package.json +44 -0
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
import { logger } from "../logger";
|
|
2
|
+
import { post } from "../requests";
|
|
3
|
+
import { MaskingLevel } from "./recorder";
|
|
4
|
+
import { assertNever } from "../utils";
|
|
5
|
+
export class EventRecorder {
|
|
6
|
+
window;
|
|
7
|
+
recorderSettings;
|
|
8
|
+
eventsBuffer = [];
|
|
9
|
+
isRunning = false;
|
|
10
|
+
tagsToCapture = new Set(["button", "a", "select", "textarea", "input"]);
|
|
11
|
+
queryParamsAllowed = new Set([
|
|
12
|
+
"utm_source",
|
|
13
|
+
"source",
|
|
14
|
+
"ref",
|
|
15
|
+
"utm_medium",
|
|
16
|
+
"medium",
|
|
17
|
+
"utm_campaign",
|
|
18
|
+
"campaign",
|
|
19
|
+
"utm_content",
|
|
20
|
+
"content",
|
|
21
|
+
"utm_term",
|
|
22
|
+
"term"
|
|
23
|
+
]);
|
|
24
|
+
// private piiAttribute = "data-pii";
|
|
25
|
+
flushIntervalMs = 2000;
|
|
26
|
+
capturedSessionId;
|
|
27
|
+
flushTimerId = null;
|
|
28
|
+
constructor(window, recorderSettings) {
|
|
29
|
+
this.window = window;
|
|
30
|
+
this.recorderSettings = recorderSettings;
|
|
31
|
+
}
|
|
32
|
+
start = () => {
|
|
33
|
+
if (this.isRunning) {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
this.isRunning = true;
|
|
37
|
+
this.eventsBuffer = [];
|
|
38
|
+
const originalPushState = this.window.history.pushState.bind(this.window.history);
|
|
39
|
+
this.window.history.pushState = (data, unused, url) => {
|
|
40
|
+
let urlString = "";
|
|
41
|
+
if (url instanceof URL) {
|
|
42
|
+
urlString = url.toString();
|
|
43
|
+
}
|
|
44
|
+
else if (url) {
|
|
45
|
+
urlString = url;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
urlString = "";
|
|
49
|
+
}
|
|
50
|
+
this.handlePageView(urlString);
|
|
51
|
+
return originalPushState(data, unused, url);
|
|
52
|
+
};
|
|
53
|
+
this.window.addEventListener("pageshow", (e) => {
|
|
54
|
+
try {
|
|
55
|
+
this.handlePageView(this.window.location.pathname);
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
logger.error("Failed to capture URL change event", error);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
this.window.addEventListener("change", this.handler);
|
|
62
|
+
this.window.addEventListener("click", this.handler);
|
|
63
|
+
this.window.addEventListener("keydown", this.handler);
|
|
64
|
+
this.scheduleFlush();
|
|
65
|
+
};
|
|
66
|
+
scheduleFlush = () => {
|
|
67
|
+
if (this.flushTimerId) {
|
|
68
|
+
clearTimeout(this.flushTimerId);
|
|
69
|
+
}
|
|
70
|
+
this.flushTimerId = setTimeout(this.flush, this.flushIntervalMs);
|
|
71
|
+
};
|
|
72
|
+
flush = async () => {
|
|
73
|
+
if (this.eventsBuffer.length > 0 && this.capturedSessionId) {
|
|
74
|
+
try {
|
|
75
|
+
const response = await post(`/public/captured-sessions/${this.capturedSessionId}/ui-events`, this.eventsBuffer, { withCredentials: false });
|
|
76
|
+
if (response.status !== 201) {
|
|
77
|
+
logger.error("Failed to save ui events", response.data);
|
|
78
|
+
}
|
|
79
|
+
this.eventsBuffer = [];
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
logger.error("Failed to save ui events", error);
|
|
83
|
+
this.eventsBuffer = [];
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
this.scheduleFlush();
|
|
87
|
+
};
|
|
88
|
+
handlePageView = (path) => {
|
|
89
|
+
if (!this.isRunning) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const timestamp = Date.now();
|
|
94
|
+
const isPathAbsolute = path.charAt(0) === "/";
|
|
95
|
+
if (!isPathAbsolute) {
|
|
96
|
+
path = this.window.location.pathname + "/" + path;
|
|
97
|
+
}
|
|
98
|
+
const url = new URL(this.window.location.protocol + "//" + this.window.location.host + path + this.window.location.search);
|
|
99
|
+
const sanitizedUrlString = this.sanitizeUrlParams(url.toString(), this.queryParamsAllowed);
|
|
100
|
+
const sanitizedUrl = new URL(sanitizedUrlString);
|
|
101
|
+
const searchParamsArray = Array.from(sanitizedUrl.searchParams);
|
|
102
|
+
const interactionEvent = {
|
|
103
|
+
eventType: "page_view",
|
|
104
|
+
timestamp,
|
|
105
|
+
text: path,
|
|
106
|
+
host: sanitizedUrl.hostname,
|
|
107
|
+
path,
|
|
108
|
+
};
|
|
109
|
+
if (searchParamsArray.length > 0) {
|
|
110
|
+
interactionEvent.queryParams = searchParamsArray;
|
|
111
|
+
}
|
|
112
|
+
this.eventsBuffer.push(interactionEvent);
|
|
113
|
+
}
|
|
114
|
+
catch (error) {
|
|
115
|
+
logger.error("Failed to capture URL change event", error);
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
sanitizeUrlParams = (url, paramsAllowed) => {
|
|
119
|
+
try {
|
|
120
|
+
const urlObj = new URL(url);
|
|
121
|
+
for (const key of urlObj.searchParams.keys()) {
|
|
122
|
+
if (!paramsAllowed.has(key.toLowerCase())) {
|
|
123
|
+
urlObj.searchParams.set(key, "$redacted");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return urlObj.toString();
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
logger.error("Failed to sanitize URL params", e);
|
|
130
|
+
return url;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
handler = (e) => {
|
|
134
|
+
if (!this.isRunning) {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const eventType = e.type;
|
|
139
|
+
const target = e.target;
|
|
140
|
+
const tagName = target?.tagName.toLowerCase();
|
|
141
|
+
if (!(tagName && this.tagsToCapture.has(tagName))) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const url = new URL(this.window.location.href);
|
|
145
|
+
const host = url.hostname;
|
|
146
|
+
const path = url.pathname;
|
|
147
|
+
const timestamp = Date.now();
|
|
148
|
+
const domContext = this.captureDomContext(target);
|
|
149
|
+
switch (e.type) {
|
|
150
|
+
case "click":
|
|
151
|
+
const clickEvent = this.handleClick(target, tagName, timestamp, host, path);
|
|
152
|
+
this.eventsBuffer.push(clickEvent);
|
|
153
|
+
break;
|
|
154
|
+
case "keydown":
|
|
155
|
+
const keyEvent = this.handleKeyDown(e, tagName, target, timestamp, host, path);
|
|
156
|
+
this.eventsBuffer.push(keyEvent);
|
|
157
|
+
break;
|
|
158
|
+
case "change":
|
|
159
|
+
const changeEvent = this.handleChange(target, tagName, timestamp, host, path);
|
|
160
|
+
this.eventsBuffer.push(changeEvent);
|
|
161
|
+
break;
|
|
162
|
+
default:
|
|
163
|
+
this.eventsBuffer.push({
|
|
164
|
+
eventType,
|
|
165
|
+
tagName,
|
|
166
|
+
timestamp,
|
|
167
|
+
host,
|
|
168
|
+
path,
|
|
169
|
+
text: null,
|
|
170
|
+
domContext
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
logger.error("Failed to capture event", error);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
handleChange(target, tagName, timestamp, host, path) {
|
|
179
|
+
let text = null;
|
|
180
|
+
if (target instanceof HTMLSelectElement) {
|
|
181
|
+
text = target.options[target.selectedIndex].text;
|
|
182
|
+
}
|
|
183
|
+
switch (this.recorderSettings.maskingLevel) {
|
|
184
|
+
case MaskingLevel.None:
|
|
185
|
+
break;
|
|
186
|
+
case MaskingLevel.All:
|
|
187
|
+
text = null;
|
|
188
|
+
break;
|
|
189
|
+
case null:
|
|
190
|
+
case MaskingLevel.InputAndTextArea:
|
|
191
|
+
break;
|
|
192
|
+
case MaskingLevel.InputPasswordOrEmailAndTextArea:
|
|
193
|
+
break;
|
|
194
|
+
default:
|
|
195
|
+
assertNever(this.recorderSettings.maskingLevel);
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
eventType: "change",
|
|
199
|
+
tagName,
|
|
200
|
+
timestamp,
|
|
201
|
+
text: text === "" ? null : text,
|
|
202
|
+
host,
|
|
203
|
+
path,
|
|
204
|
+
domContext: this.captureDomContext(target)
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
handleClick(target, tagName, timestamp, host, path) {
|
|
208
|
+
let text = "";
|
|
209
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
210
|
+
const labelText = this.findInputOrSelectOrTextAreaLabel(target);
|
|
211
|
+
if (labelText) {
|
|
212
|
+
text = labelText;
|
|
213
|
+
}
|
|
214
|
+
else if (target.placeholder) {
|
|
215
|
+
text = target.placeholder;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (target instanceof HTMLSelectElement) {
|
|
219
|
+
const labelText = this.findInputOrSelectOrTextAreaLabel(target);
|
|
220
|
+
if (labelText) {
|
|
221
|
+
text = labelText;
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
text = target.value + " (" + target.options[target.selectedIndex].text + ")";
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (target instanceof HTMLButtonElement) {
|
|
228
|
+
text = target.innerText;
|
|
229
|
+
}
|
|
230
|
+
else if (target instanceof HTMLAnchorElement) {
|
|
231
|
+
text = target.href;
|
|
232
|
+
}
|
|
233
|
+
else if (target instanceof HTMLImageElement) {
|
|
234
|
+
text = target.src;
|
|
235
|
+
}
|
|
236
|
+
switch (this.recorderSettings.maskingLevel) {
|
|
237
|
+
case MaskingLevel.None:
|
|
238
|
+
break;
|
|
239
|
+
case MaskingLevel.All:
|
|
240
|
+
text = null;
|
|
241
|
+
break;
|
|
242
|
+
case null:
|
|
243
|
+
case MaskingLevel.InputAndTextArea:
|
|
244
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
245
|
+
text = null;
|
|
246
|
+
}
|
|
247
|
+
break;
|
|
248
|
+
case MaskingLevel.InputPasswordOrEmailAndTextArea:
|
|
249
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
250
|
+
text = null;
|
|
251
|
+
}
|
|
252
|
+
else if (target instanceof HTMLInputElement) {
|
|
253
|
+
if (target.type === "password" || target.type === "email" || target.type === "e-mail") {
|
|
254
|
+
text = null;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
break;
|
|
258
|
+
default:
|
|
259
|
+
assertNever(this.recorderSettings.maskingLevel);
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
eventType: "click",
|
|
263
|
+
tagName,
|
|
264
|
+
timestamp,
|
|
265
|
+
text: text === "" ? null : text,
|
|
266
|
+
host,
|
|
267
|
+
path,
|
|
268
|
+
domContext: this.captureDomContext(target)
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
handleKeyDown(e, tagName, target, timestamp, host, path) {
|
|
272
|
+
const kbe = e;
|
|
273
|
+
const key = kbe.key;
|
|
274
|
+
let text = null;
|
|
275
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
276
|
+
const labelText = this.findInputOrSelectOrTextAreaLabel(target);
|
|
277
|
+
if (labelText) {
|
|
278
|
+
text = labelText;
|
|
279
|
+
}
|
|
280
|
+
else if (target.placeholder) {
|
|
281
|
+
text = target.placeholder;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
switch (this.recorderSettings.maskingLevel) {
|
|
285
|
+
case MaskingLevel.None:
|
|
286
|
+
break;
|
|
287
|
+
case MaskingLevel.All:
|
|
288
|
+
text = null;
|
|
289
|
+
break;
|
|
290
|
+
case null:
|
|
291
|
+
case MaskingLevel.InputAndTextArea:
|
|
292
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
|
|
293
|
+
text = null;
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
case MaskingLevel.InputPasswordOrEmailAndTextArea:
|
|
297
|
+
if (target instanceof HTMLTextAreaElement) {
|
|
298
|
+
text = null;
|
|
299
|
+
}
|
|
300
|
+
else if (target instanceof HTMLInputElement) {
|
|
301
|
+
if (target.type === "password" || target.type === "email" || target.type === "e-mail") {
|
|
302
|
+
text = null;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
break;
|
|
306
|
+
default:
|
|
307
|
+
assertNever(this.recorderSettings.maskingLevel);
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
eventType: "keydown",
|
|
311
|
+
tagName,
|
|
312
|
+
timestamp,
|
|
313
|
+
text,
|
|
314
|
+
key,
|
|
315
|
+
host,
|
|
316
|
+
path,
|
|
317
|
+
domContext: this.captureDomContext(target)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
findInputOrSelectOrTextAreaLabel = (el) => {
|
|
321
|
+
const allowedTags = new Set(["input", "select", "textarea"]);
|
|
322
|
+
if (!el || !allowedTags.has(el.tagName.toLowerCase()))
|
|
323
|
+
return null;
|
|
324
|
+
/* 1. <label><input|select …></label> (label wraps the control) */
|
|
325
|
+
const wrappingLabel = el.closest("label");
|
|
326
|
+
if (wrappingLabel) {
|
|
327
|
+
// Take only the TEXT_NODEs that are direct children of <label>
|
|
328
|
+
const text = Array.from(wrappingLabel.childNodes)
|
|
329
|
+
.filter(node => node.nodeName.toLowerCase() !== "select")
|
|
330
|
+
.map(node => (node.textContent ?? "").trim())
|
|
331
|
+
.filter(Boolean) // remove empty strings
|
|
332
|
+
.join(" ")
|
|
333
|
+
.trim();
|
|
334
|
+
if (text)
|
|
335
|
+
return text;
|
|
336
|
+
}
|
|
337
|
+
/* 2. <label for="id">…</label> + <input|select id="id" …> */
|
|
338
|
+
const id = el.id;
|
|
339
|
+
if (id) {
|
|
340
|
+
const explicitLabel = el.ownerDocument.querySelector(`label[for="${CSS.escape(id)}"]`);
|
|
341
|
+
if (explicitLabel) {
|
|
342
|
+
const text = Array.from(explicitLabel.childNodes)
|
|
343
|
+
.filter(node => node.nodeName.toLowerCase() !== "select")
|
|
344
|
+
.map(node => (node.textContent ?? "").trim())
|
|
345
|
+
.filter(Boolean)
|
|
346
|
+
.join(" ")
|
|
347
|
+
.trim();
|
|
348
|
+
if (text)
|
|
349
|
+
return text;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/* 3. Accessibility fall-backs */
|
|
353
|
+
const ariaLabel = el.getAttribute("aria-label");
|
|
354
|
+
if (ariaLabel)
|
|
355
|
+
return ariaLabel.trim();
|
|
356
|
+
const ariaLabelledBy = el.getAttribute("aria-labelledby");
|
|
357
|
+
if (ariaLabelledBy) {
|
|
358
|
+
const ref = el.ownerDocument.getElementById(ariaLabelledBy);
|
|
359
|
+
return ref?.textContent?.trim() || null;
|
|
360
|
+
}
|
|
361
|
+
return null;
|
|
362
|
+
};
|
|
363
|
+
stop = () => {
|
|
364
|
+
this.isRunning = false;
|
|
365
|
+
this.window.removeEventListener("change", this.handler);
|
|
366
|
+
this.window.removeEventListener("click", this.handler);
|
|
367
|
+
this.window.removeEventListener("keydown", this.handler);
|
|
368
|
+
if (this.flushTimerId) {
|
|
369
|
+
clearTimeout(this.flushTimerId);
|
|
370
|
+
this.flushTimerId = null;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
captureDomContext = (targetElement) => {
|
|
374
|
+
const context = [];
|
|
375
|
+
context.push(this.toJsonNode(targetElement));
|
|
376
|
+
let current = targetElement.parentElement;
|
|
377
|
+
while (current != null && context.length < 6) {
|
|
378
|
+
context.push(this.toJsonNode(current));
|
|
379
|
+
current = current.parentElement;
|
|
380
|
+
}
|
|
381
|
+
return context;
|
|
382
|
+
};
|
|
383
|
+
toJsonNode = (node) => {
|
|
384
|
+
const attrs = {};
|
|
385
|
+
// Collect safe attributes only
|
|
386
|
+
["id", "class", "name", "type", "role", "aria-label"].forEach((key) => {
|
|
387
|
+
const val = node.getAttribute(key);
|
|
388
|
+
if (val)
|
|
389
|
+
attrs[key] = val;
|
|
390
|
+
});
|
|
391
|
+
return {
|
|
392
|
+
tag: node.tagName.toLowerCase(),
|
|
393
|
+
attrs
|
|
394
|
+
};
|
|
395
|
+
};
|
|
396
|
+
setCapturedSessionId(uuid) {
|
|
397
|
+
this.capturedSessionId = uuid;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
export declare class Recorder {
|
|
2
|
+
private window;
|
|
3
|
+
private publicToken;
|
|
4
|
+
private recorderSettings;
|
|
5
|
+
private sessionRecorder;
|
|
6
|
+
private eventRecorder;
|
|
7
|
+
private capturedSessionId;
|
|
8
|
+
private pingIntervalMs;
|
|
9
|
+
private pingTimeout;
|
|
10
|
+
constructor(window: Window, publicToken: string, recorderSettings: RecorderSettings);
|
|
11
|
+
private schedulePing;
|
|
12
|
+
private ping;
|
|
13
|
+
/**
|
|
14
|
+
* Start both recorders
|
|
15
|
+
*/
|
|
16
|
+
start(): void;
|
|
17
|
+
/**
|
|
18
|
+
* Stop both recorders
|
|
19
|
+
*/
|
|
20
|
+
stop(): void;
|
|
21
|
+
private collectCapturedUserMetadata;
|
|
22
|
+
}
|
|
23
|
+
export interface RecorderSettings {
|
|
24
|
+
maskingLevel: MaskingLevel | null;
|
|
25
|
+
}
|
|
26
|
+
export declare enum MaskingLevel {
|
|
27
|
+
None = "none",
|
|
28
|
+
All = "all",
|
|
29
|
+
InputAndTextArea = "input-and-textarea",
|
|
30
|
+
InputPasswordOrEmailAndTextArea = "input-password-or-email-and-textarea"
|
|
31
|
+
}
|
|
32
|
+
export interface CapturedUserMetadata {
|
|
33
|
+
browserName?: string;
|
|
34
|
+
browserVersion?: string;
|
|
35
|
+
osName?: string;
|
|
36
|
+
osVersion?: string;
|
|
37
|
+
deviceType?: string;
|
|
38
|
+
browserLanguage?: string;
|
|
39
|
+
browserTimeZone?: string;
|
|
40
|
+
referringUrl?: string;
|
|
41
|
+
referringDomain?: string;
|
|
42
|
+
viewportWidth?: number;
|
|
43
|
+
viewportHeight?: number;
|
|
44
|
+
utmSource?: string;
|
|
45
|
+
utmMedium?: string;
|
|
46
|
+
utmCampaign?: string;
|
|
47
|
+
utmContent?: string;
|
|
48
|
+
utmTerm?: string;
|
|
49
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { SessionRecorder } from "./sessionRecorder";
|
|
2
|
+
import { EventRecorder } from "./eventRecorder";
|
|
3
|
+
import { post, put } from "../requests";
|
|
4
|
+
import { UAParser } from "ua-parser-js";
|
|
5
|
+
export class Recorder {
|
|
6
|
+
window;
|
|
7
|
+
publicToken;
|
|
8
|
+
recorderSettings;
|
|
9
|
+
sessionRecorder;
|
|
10
|
+
eventRecorder;
|
|
11
|
+
capturedSessionId = null;
|
|
12
|
+
pingIntervalMs = 20000;
|
|
13
|
+
pingTimeout = null;
|
|
14
|
+
constructor(window, publicToken, recorderSettings) {
|
|
15
|
+
this.window = window;
|
|
16
|
+
this.publicToken = publicToken;
|
|
17
|
+
this.recorderSettings = recorderSettings;
|
|
18
|
+
if (recorderSettings.maskingLevel == null) {
|
|
19
|
+
recorderSettings.maskingLevel = MaskingLevel.InputAndTextArea;
|
|
20
|
+
}
|
|
21
|
+
this.sessionRecorder = new SessionRecorder(recorderSettings);
|
|
22
|
+
this.eventRecorder = new EventRecorder(window, recorderSettings);
|
|
23
|
+
post(`public/captured-sessions`, { publicToken }, { withCredentials: false })
|
|
24
|
+
.then(response => {
|
|
25
|
+
const id = response.data;
|
|
26
|
+
this.capturedSessionId = id;
|
|
27
|
+
this.sessionRecorder.setCapturedSessionId(id);
|
|
28
|
+
this.eventRecorder.setCapturedSessionId(id);
|
|
29
|
+
this.schedulePing();
|
|
30
|
+
const capturedUserMetadata = this.collectCapturedUserMetadata();
|
|
31
|
+
post(`public/captured-sessions/${this.capturedSessionId}/captured-session/metadata`, capturedUserMetadata, { withCredentials: false });
|
|
32
|
+
})
|
|
33
|
+
.catch(error => {
|
|
34
|
+
console.error(error);
|
|
35
|
+
this.sessionRecorder.stop();
|
|
36
|
+
this.eventRecorder.stop();
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
schedulePing() {
|
|
40
|
+
if (this.pingTimeout) {
|
|
41
|
+
clearTimeout(this.pingTimeout);
|
|
42
|
+
}
|
|
43
|
+
this.pingTimeout = setTimeout(this.ping, this.pingIntervalMs);
|
|
44
|
+
}
|
|
45
|
+
ping = async () => {
|
|
46
|
+
await put(`public/captured-sessions/${this.capturedSessionId}/ping`, {}, { withCredentials: false });
|
|
47
|
+
this.schedulePing();
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Start both recorders
|
|
51
|
+
*/
|
|
52
|
+
start() {
|
|
53
|
+
this.sessionRecorder.start();
|
|
54
|
+
this.eventRecorder.start();
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Stop both recorders
|
|
58
|
+
*/
|
|
59
|
+
stop() {
|
|
60
|
+
this.sessionRecorder.stop();
|
|
61
|
+
this.eventRecorder.stop();
|
|
62
|
+
}
|
|
63
|
+
collectCapturedUserMetadata = () => {
|
|
64
|
+
const ua = new UAParser();
|
|
65
|
+
const browserName = ua.getBrowser().name;
|
|
66
|
+
const browserVersion = ua.getBrowser().version;
|
|
67
|
+
const osName = ua.getOS().name;
|
|
68
|
+
const osVersion = ua.getOS().version;
|
|
69
|
+
const deviceType = (ua.getDevice().type ?? "desktop"); // undefined ⇒ desktop
|
|
70
|
+
const browserLanguage = navigator.language;
|
|
71
|
+
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
72
|
+
const referringUrl = document.referrer || undefined;
|
|
73
|
+
let referringDomain;
|
|
74
|
+
try {
|
|
75
|
+
referringDomain = referringUrl ? new URL(referringUrl).hostname : undefined;
|
|
76
|
+
}
|
|
77
|
+
catch { /* ignore malformed referrer */ }
|
|
78
|
+
const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
|
79
|
+
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
|
80
|
+
const url = new URL(window.location.href);
|
|
81
|
+
const utmSource = url.searchParams.get("utm_source") || url.searchParams.get("source") || url.searchParams.get("ref") || undefined;
|
|
82
|
+
const utmMedium = url.searchParams.get("utm_medium") || url.searchParams.get("medium") || undefined;
|
|
83
|
+
const utmCampaign = url.searchParams.get("utm_campaign") || url.searchParams.get("campaign") || undefined;
|
|
84
|
+
const utmContent = url.searchParams.get("utm_content") || url.searchParams.get("content") || undefined;
|
|
85
|
+
const utmTerm = url.searchParams.get("utm_term") || url.searchParams.get("term") || undefined;
|
|
86
|
+
return {
|
|
87
|
+
browserName,
|
|
88
|
+
browserVersion,
|
|
89
|
+
osName,
|
|
90
|
+
osVersion,
|
|
91
|
+
deviceType,
|
|
92
|
+
browserLanguage,
|
|
93
|
+
browserTimeZone,
|
|
94
|
+
referringUrl,
|
|
95
|
+
referringDomain,
|
|
96
|
+
viewportWidth,
|
|
97
|
+
viewportHeight,
|
|
98
|
+
utmSource,
|
|
99
|
+
utmMedium,
|
|
100
|
+
utmCampaign,
|
|
101
|
+
utmContent,
|
|
102
|
+
utmTerm
|
|
103
|
+
};
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export var MaskingLevel;
|
|
107
|
+
(function (MaskingLevel) {
|
|
108
|
+
MaskingLevel["None"] = "none";
|
|
109
|
+
MaskingLevel["All"] = "all";
|
|
110
|
+
MaskingLevel["InputAndTextArea"] = "input-and-textarea";
|
|
111
|
+
MaskingLevel["InputPasswordOrEmailAndTextArea"] = "input-password-or-email-and-textarea";
|
|
112
|
+
})(MaskingLevel || (MaskingLevel = {}));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { type RecorderSettings } from "./recorder";
|
|
2
|
+
export interface SessionBuffer {
|
|
3
|
+
data: any[];
|
|
4
|
+
}
|
|
5
|
+
export declare class SessionRecorder {
|
|
6
|
+
private recorderSettings;
|
|
7
|
+
private capturedSessionId;
|
|
8
|
+
private buffer;
|
|
9
|
+
private stopFn;
|
|
10
|
+
private readonly flushIntervalMs;
|
|
11
|
+
private flushTimer;
|
|
12
|
+
constructor(recorderSettings: RecorderSettings);
|
|
13
|
+
setCapturedSessionId(id: string): void;
|
|
14
|
+
start: () => void;
|
|
15
|
+
private scheduleFlush;
|
|
16
|
+
private flush;
|
|
17
|
+
stop: () => void;
|
|
18
|
+
private clearAllBufferData;
|
|
19
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { record } from "rrweb";
|
|
2
|
+
import { patch } from "../requests";
|
|
3
|
+
import { logger } from "../logger";
|
|
4
|
+
import { MaskingLevel } from "./recorder";
|
|
5
|
+
import { assertNever } from "../utils";
|
|
6
|
+
export class SessionRecorder {
|
|
7
|
+
recorderSettings;
|
|
8
|
+
capturedSessionId = null;
|
|
9
|
+
buffer;
|
|
10
|
+
stopFn;
|
|
11
|
+
flushIntervalMs;
|
|
12
|
+
flushTimer;
|
|
13
|
+
constructor(recorderSettings) {
|
|
14
|
+
this.recorderSettings = recorderSettings;
|
|
15
|
+
this.buffer = {
|
|
16
|
+
data: []
|
|
17
|
+
};
|
|
18
|
+
this.flushIntervalMs = 2000; // 2 * 1000
|
|
19
|
+
}
|
|
20
|
+
setCapturedSessionId(id) {
|
|
21
|
+
this.capturedSessionId = id;
|
|
22
|
+
}
|
|
23
|
+
start = () => {
|
|
24
|
+
if (Object.assign === undefined) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
this.buffer = {
|
|
28
|
+
data: []
|
|
29
|
+
};
|
|
30
|
+
const recordOptions = {
|
|
31
|
+
emit: (event) => {
|
|
32
|
+
this.buffer.data.push(event);
|
|
33
|
+
},
|
|
34
|
+
blockClass: "scry-block"
|
|
35
|
+
};
|
|
36
|
+
switch (this.recorderSettings.maskingLevel) {
|
|
37
|
+
case MaskingLevel.None:
|
|
38
|
+
break;
|
|
39
|
+
case MaskingLevel.All:
|
|
40
|
+
recordOptions.maskTextFn = (input) => input.replaceAll(/./g, "*");
|
|
41
|
+
break;
|
|
42
|
+
case null:
|
|
43
|
+
case MaskingLevel.InputAndTextArea:
|
|
44
|
+
recordOptions.maskAllInputs = true;
|
|
45
|
+
break;
|
|
46
|
+
case MaskingLevel.InputPasswordOrEmailAndTextArea:
|
|
47
|
+
recordOptions.maskInputOptions = {
|
|
48
|
+
password: true,
|
|
49
|
+
email: true,
|
|
50
|
+
textarea: true
|
|
51
|
+
};
|
|
52
|
+
break;
|
|
53
|
+
default:
|
|
54
|
+
assertNever(this.recorderSettings.maskingLevel);
|
|
55
|
+
}
|
|
56
|
+
this.stopFn = record(recordOptions);
|
|
57
|
+
this.scheduleFlush();
|
|
58
|
+
};
|
|
59
|
+
// separate method for scheduling flush
|
|
60
|
+
scheduleFlush = () => {
|
|
61
|
+
if (this.flushTimer) {
|
|
62
|
+
clearTimeout(this.flushTimer);
|
|
63
|
+
}
|
|
64
|
+
this.flushTimer = setTimeout(this.flush, this.flushIntervalMs);
|
|
65
|
+
};
|
|
66
|
+
// preserve data on flush failure
|
|
67
|
+
flush = async () => {
|
|
68
|
+
if (this.flushTimer) {
|
|
69
|
+
clearTimeout(this.flushTimer);
|
|
70
|
+
this.flushTimer = undefined;
|
|
71
|
+
}
|
|
72
|
+
if (this.capturedSessionId && this.buffer.data.length > 0) {
|
|
73
|
+
try {
|
|
74
|
+
let response = await patch(`public/captured-sessions/${this.capturedSessionId}/recording`, this.buffer, { withCredentials: false });
|
|
75
|
+
if (response.status >= 400) {
|
|
76
|
+
logger.error(`status ${response.status} when trying to save session recording data.`);
|
|
77
|
+
logger.error(response.data);
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
// clear buffer only if response was successful
|
|
81
|
+
this.clearAllBufferData();
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
logger.error("Error flushing session data:", error);
|
|
86
|
+
// TODO add a failure count that will eventually stop, so we don't store a ton of data and hog client memory.
|
|
87
|
+
// do not clear the buffer, so we retry next time
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
this.scheduleFlush();
|
|
91
|
+
};
|
|
92
|
+
stop = () => {
|
|
93
|
+
if (this.stopFn) {
|
|
94
|
+
this.stopFn();
|
|
95
|
+
}
|
|
96
|
+
if (this.flushTimer) {
|
|
97
|
+
clearTimeout(this.flushTimer);
|
|
98
|
+
this.flushTimer = undefined;
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
clearAllBufferData = () => {
|
|
102
|
+
this.buffer.data = [];
|
|
103
|
+
};
|
|
104
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { type AxiosRequestConfig } from "axios";
|
|
2
|
+
export declare const get: <T = unknown>(url: string, config?: AxiosRequestConfig) => Promise<import("axios").AxiosResponse<T, any>>;
|
|
3
|
+
export declare const post: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => Promise<import("axios").AxiosResponse<T, any>>;
|
|
4
|
+
export declare const del: <T = unknown>(url: string, config?: AxiosRequestConfig) => Promise<import("axios").AxiosResponse<T, any>>;
|
|
5
|
+
export declare const put: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => Promise<import("axios").AxiosResponse<T, any>>;
|
|
6
|
+
export declare const patch: <T = unknown>(url: string, data?: unknown, config?: AxiosRequestConfig) => Promise<import("axios").AxiosResponse<T, any>>;
|
|
7
|
+
export declare const postForm: <T = unknown>(url: string, data: unknown, config?: AxiosRequestConfig) => Promise<import("axios").AxiosResponse<T, any>>;
|