@affogatosoftware/recorder 1.0.8 → 1.1.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/dist/browser/recorder.iife.js +3 -1
- package/dist/browser/recorder.iife.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/recorder/errorRecorder.d.ts +29 -0
- package/dist/recorder/errorRecorder.js +143 -0
- package/dist/recorder/eventRecorder.js +16 -19
- package/dist/recorder/networkRecorder.d.ts +54 -0
- package/dist/recorder/networkRecorder.js +376 -0
- package/dist/recorder/recorder.d.ts +21 -11
- package/dist/recorder/recorder.js +48 -17
- package/dist/recorder/sessionRecorder.js +9 -9
- package/package.json +1 -1
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { logger } from "../logger";
|
|
2
|
+
import { post } from "../requests";
|
|
3
|
+
export class NetworkRecorder {
|
|
4
|
+
window;
|
|
5
|
+
requestsBuffer = [];
|
|
6
|
+
pendingRequests = new Map();
|
|
7
|
+
isRunning = false;
|
|
8
|
+
flushIntervalMs = 2000;
|
|
9
|
+
capturedSessionId;
|
|
10
|
+
flushTimerId = null;
|
|
11
|
+
// Store original implementations
|
|
12
|
+
originalFetch;
|
|
13
|
+
originalXHROpen;
|
|
14
|
+
originalXHRSend;
|
|
15
|
+
// URL sanitization - reuse pattern from EventRecorder
|
|
16
|
+
queryParamsAllowed = new Set([
|
|
17
|
+
"utm_source", "source", "ref", "utm_medium", "medium",
|
|
18
|
+
"utm_campaign", "campaign", "utm_content", "content", "utm_term", "term"
|
|
19
|
+
]);
|
|
20
|
+
networkSettings = {
|
|
21
|
+
enabled: false, // Default disabled
|
|
22
|
+
maxRequestBodySize: 10 * 1024, // 10KB
|
|
23
|
+
maxResponseBodySize: 50 * 1024, // 50KB
|
|
24
|
+
excludeDomains: [],
|
|
25
|
+
captureHeaders: false,
|
|
26
|
+
captureRequestBodies: false,
|
|
27
|
+
captureResponseBodies: true,
|
|
28
|
+
excludeHeaders: []
|
|
29
|
+
};
|
|
30
|
+
constructor(window, settings) {
|
|
31
|
+
this.window = window;
|
|
32
|
+
this.originalFetch = this.window.fetch.bind(this.window);
|
|
33
|
+
this.originalXHROpen = XMLHttpRequest.prototype.open;
|
|
34
|
+
this.originalXHRSend = XMLHttpRequest.prototype.send;
|
|
35
|
+
if (settings) {
|
|
36
|
+
this.networkSettings = { ...this.networkSettings, ...settings };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
start = () => {
|
|
40
|
+
if (this.isRunning || !this.networkSettings.enabled) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this.isRunning = true;
|
|
44
|
+
this.requestsBuffer = [];
|
|
45
|
+
this.patchFetch();
|
|
46
|
+
this.patchXHR();
|
|
47
|
+
this.scheduleFlush();
|
|
48
|
+
};
|
|
49
|
+
stop = () => {
|
|
50
|
+
this.isRunning = false;
|
|
51
|
+
// Restore original implementations
|
|
52
|
+
this.window.fetch = this.originalFetch;
|
|
53
|
+
XMLHttpRequest.prototype.open = this.originalXHROpen;
|
|
54
|
+
XMLHttpRequest.prototype.send = this.originalXHRSend;
|
|
55
|
+
if (this.flushTimerId) {
|
|
56
|
+
clearTimeout(this.flushTimerId);
|
|
57
|
+
this.flushTimerId = null;
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
setCapturedSessionId(uuid) {
|
|
61
|
+
this.capturedSessionId = uuid;
|
|
62
|
+
}
|
|
63
|
+
scheduleFlush = () => {
|
|
64
|
+
if (this.flushTimerId) {
|
|
65
|
+
clearTimeout(this.flushTimerId);
|
|
66
|
+
}
|
|
67
|
+
this.flushTimerId = setTimeout(this.flush, this.flushIntervalMs);
|
|
68
|
+
};
|
|
69
|
+
flush = async () => {
|
|
70
|
+
if (this.requestsBuffer.length > 0 && this.capturedSessionId) {
|
|
71
|
+
try {
|
|
72
|
+
const response = await post(`/public/captured-sessions/${this.capturedSessionId}/network-requests`, this.requestsBuffer, { withCredentials: false });
|
|
73
|
+
if (response.status !== 201) {
|
|
74
|
+
logger.error("Failed to save network requests", response.data);
|
|
75
|
+
}
|
|
76
|
+
this.requestsBuffer = [];
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
logger.error("Failed to save network requests", error);
|
|
80
|
+
this.requestsBuffer = [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
this.scheduleFlush();
|
|
84
|
+
};
|
|
85
|
+
patchFetch = () => {
|
|
86
|
+
const self = this;
|
|
87
|
+
this.window.fetch = async function (input, init) {
|
|
88
|
+
const requestId = self.generateRequestId();
|
|
89
|
+
const timestamp = Date.now();
|
|
90
|
+
let url = "";
|
|
91
|
+
let method = "GET";
|
|
92
|
+
let requestHeaders = {};
|
|
93
|
+
let requestBody;
|
|
94
|
+
// Parse input to extract URL and method
|
|
95
|
+
if (typeof input === "string") {
|
|
96
|
+
url = input;
|
|
97
|
+
}
|
|
98
|
+
else if (input instanceof URL) {
|
|
99
|
+
url = input.toString();
|
|
100
|
+
}
|
|
101
|
+
else if (input instanceof Request) {
|
|
102
|
+
url = input.url;
|
|
103
|
+
method = input.method;
|
|
104
|
+
if (self.networkSettings.captureHeaders) {
|
|
105
|
+
input.headers.forEach((value, key) => {
|
|
106
|
+
requestHeaders[key] = value;
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
if (self.networkSettings.captureRequestBodies && input.body) {
|
|
110
|
+
try {
|
|
111
|
+
const clonedRequest = input.clone();
|
|
112
|
+
requestBody = await clonedRequest.text();
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
// Body already consumed, skip
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (init) {
|
|
120
|
+
method = init.method || method;
|
|
121
|
+
if (self.networkSettings.captureHeaders && init.headers) {
|
|
122
|
+
if (init.headers instanceof Headers) {
|
|
123
|
+
init.headers.forEach((value, key) => {
|
|
124
|
+
requestHeaders[key] = value;
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
else if (Array.isArray(init.headers)) {
|
|
128
|
+
init.headers.forEach(([key, value]) => {
|
|
129
|
+
requestHeaders[key] = value;
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
Object.assign(requestHeaders, init.headers);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (self.networkSettings.captureRequestBodies && init.body) {
|
|
137
|
+
if (typeof init.body === "string") {
|
|
138
|
+
requestBody = init.body;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
try {
|
|
142
|
+
requestBody = JSON.stringify(init.body);
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
requestBody = "[Unable to serialize body]";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!self.shouldCaptureRequest(url)) {
|
|
151
|
+
return self.originalFetch(input, init);
|
|
152
|
+
}
|
|
153
|
+
const sanitizedUrl = self.sanitizeUrl(url);
|
|
154
|
+
const filteredHeaders = self.filterHeaders(requestHeaders);
|
|
155
|
+
const truncatedBody = self.truncateContent(requestBody, self.networkSettings.maxRequestBodySize);
|
|
156
|
+
// Create initial request record
|
|
157
|
+
const networkRequest = {
|
|
158
|
+
requestId,
|
|
159
|
+
type: "fetch",
|
|
160
|
+
method: method.toUpperCase(),
|
|
161
|
+
url: sanitizedUrl,
|
|
162
|
+
requestHeaders: Object.keys(filteredHeaders).length > 0 ? filteredHeaders : undefined,
|
|
163
|
+
requestBody: truncatedBody,
|
|
164
|
+
timestamp
|
|
165
|
+
};
|
|
166
|
+
self.pendingRequests.set(requestId, networkRequest);
|
|
167
|
+
try {
|
|
168
|
+
const response = await self.originalFetch(input, init);
|
|
169
|
+
const duration = Date.now() - timestamp;
|
|
170
|
+
let responseHeaders = {};
|
|
171
|
+
let responseBody;
|
|
172
|
+
if (self.networkSettings.captureHeaders) {
|
|
173
|
+
response.headers.forEach((value, key) => {
|
|
174
|
+
responseHeaders[key] = value;
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
if (self.networkSettings.captureResponseBodies) {
|
|
178
|
+
try {
|
|
179
|
+
const clonedResponse = response.clone();
|
|
180
|
+
const text = await clonedResponse.text();
|
|
181
|
+
responseBody = self.truncateContent(text, self.networkSettings.maxResponseBodySize);
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
responseBody = "[Unable to read response body]";
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// Complete the request record
|
|
188
|
+
const completedRequest = {
|
|
189
|
+
...networkRequest,
|
|
190
|
+
responseStatus: response.status,
|
|
191
|
+
responseHeaders: Object.keys(responseHeaders).length > 0 ? self.filterHeaders(responseHeaders) : undefined,
|
|
192
|
+
responseBody,
|
|
193
|
+
duration
|
|
194
|
+
};
|
|
195
|
+
self.addCompletedRequest(requestId, completedRequest);
|
|
196
|
+
return response;
|
|
197
|
+
}
|
|
198
|
+
catch (error) {
|
|
199
|
+
const duration = Date.now() - timestamp;
|
|
200
|
+
const errorRequest = {
|
|
201
|
+
...networkRequest,
|
|
202
|
+
error: error instanceof Error ? error.message : String(error),
|
|
203
|
+
duration
|
|
204
|
+
};
|
|
205
|
+
self.addCompletedRequest(requestId, errorRequest);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
patchXHR = () => {
|
|
211
|
+
const self = this;
|
|
212
|
+
XMLHttpRequest.prototype.open = function (method, url, async, username, password) {
|
|
213
|
+
const xhr = this;
|
|
214
|
+
const urlString = typeof url === "string" ? url : url.toString();
|
|
215
|
+
if (self.shouldCaptureRequest(urlString)) {
|
|
216
|
+
const requestId = self.generateRequestId();
|
|
217
|
+
const timestamp = Date.now();
|
|
218
|
+
xhr.__networkRecorder = {
|
|
219
|
+
requestId,
|
|
220
|
+
timestamp,
|
|
221
|
+
method: method.toUpperCase(),
|
|
222
|
+
url: self.sanitizeUrl(urlString)
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
return self.originalXHROpen.call(this, method, url, async ?? true, username, password);
|
|
226
|
+
};
|
|
227
|
+
XMLHttpRequest.prototype.send = function (body) {
|
|
228
|
+
const xhr = this;
|
|
229
|
+
if (xhr.__networkRecorder) {
|
|
230
|
+
const { requestId, timestamp, method, url } = xhr.__networkRecorder;
|
|
231
|
+
let requestHeaders = {};
|
|
232
|
+
let requestBody;
|
|
233
|
+
if (self.networkSettings.captureRequestBodies && body) {
|
|
234
|
+
if (typeof body === "string") {
|
|
235
|
+
requestBody = body;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
try {
|
|
239
|
+
requestBody = JSON.stringify(body);
|
|
240
|
+
}
|
|
241
|
+
catch (e) {
|
|
242
|
+
requestBody = "[Unable to serialize body]";
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const networkRequest = {
|
|
247
|
+
requestId,
|
|
248
|
+
type: "xhr",
|
|
249
|
+
method,
|
|
250
|
+
url,
|
|
251
|
+
requestHeaders: Object.keys(requestHeaders).length > 0 ? self.filterHeaders(requestHeaders) : undefined,
|
|
252
|
+
requestBody: self.truncateContent(requestBody, self.networkSettings.maxRequestBodySize),
|
|
253
|
+
timestamp
|
|
254
|
+
};
|
|
255
|
+
self.pendingRequests.set(requestId, networkRequest);
|
|
256
|
+
// Handle response
|
|
257
|
+
const originalOnReadyStateChange = xhr.onreadystatechange;
|
|
258
|
+
xhr.onreadystatechange = function () {
|
|
259
|
+
if (xhr.readyState === XMLHttpRequest.DONE) {
|
|
260
|
+
const duration = Date.now() - timestamp;
|
|
261
|
+
let responseHeaders = {};
|
|
262
|
+
let responseBody;
|
|
263
|
+
if (self.networkSettings.captureHeaders) {
|
|
264
|
+
const headerString = xhr.getAllResponseHeaders();
|
|
265
|
+
if (headerString) {
|
|
266
|
+
headerString.split('\r\n').forEach(line => {
|
|
267
|
+
const parts = line.split(': ');
|
|
268
|
+
if (parts.length === 2) {
|
|
269
|
+
responseHeaders[parts[0]] = parts[1];
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (self.networkSettings.captureResponseBodies) {
|
|
275
|
+
try {
|
|
276
|
+
responseBody = self.truncateContent(xhr.responseText, self.networkSettings.maxResponseBodySize);
|
|
277
|
+
}
|
|
278
|
+
catch (e) {
|
|
279
|
+
responseBody = "[Unable to read response]";
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const completedRequest = {
|
|
283
|
+
...networkRequest,
|
|
284
|
+
responseStatus: xhr.status,
|
|
285
|
+
responseHeaders: Object.keys(responseHeaders).length > 0 ? self.filterHeaders(responseHeaders) : undefined,
|
|
286
|
+
responseBody,
|
|
287
|
+
duration,
|
|
288
|
+
error: xhr.status === 0 ? "Network error" : undefined
|
|
289
|
+
};
|
|
290
|
+
self.addCompletedRequest(requestId, completedRequest);
|
|
291
|
+
}
|
|
292
|
+
if (originalOnReadyStateChange) {
|
|
293
|
+
originalOnReadyStateChange.call(this, new Event('readystatechange'));
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
return self.originalXHRSend.call(this, body ?? null);
|
|
298
|
+
};
|
|
299
|
+
};
|
|
300
|
+
shouldCaptureRequest(url) {
|
|
301
|
+
if (!this.isRunning || !this.networkSettings.enabled) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
try {
|
|
305
|
+
const urlObj = new URL(url, this.window.location.href);
|
|
306
|
+
// Don't capture requests to our own API endpoints
|
|
307
|
+
if (urlObj.pathname.includes('/public/captured-sessions')) {
|
|
308
|
+
return false;
|
|
309
|
+
}
|
|
310
|
+
// Check excluded domains
|
|
311
|
+
if (this.networkSettings.excludeDomains.some(domain => urlObj.hostname.includes(domain))) {
|
|
312
|
+
return false;
|
|
313
|
+
}
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
return false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
sanitizeUrl(url) {
|
|
321
|
+
try {
|
|
322
|
+
const urlObj = new URL(url, this.window.location.href);
|
|
323
|
+
// Apply same sanitization as EventRecorder
|
|
324
|
+
for (const key of urlObj.searchParams.keys()) {
|
|
325
|
+
if (!this.queryParamsAllowed.has(key.toLowerCase())) {
|
|
326
|
+
urlObj.searchParams.set(key, "$redacted");
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return urlObj.toString();
|
|
330
|
+
}
|
|
331
|
+
catch (e) {
|
|
332
|
+
logger.error("Failed to sanitize URL", e);
|
|
333
|
+
return url;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
filterHeaders(headers) {
|
|
337
|
+
const filtered = {};
|
|
338
|
+
const defaultSensitiveHeaders = new Set([
|
|
339
|
+
'authorization', 'cookie', 'x-api-key', 'x-auth-token',
|
|
340
|
+
'x-csrf-token', 'x-session-token', 'set-cookie'
|
|
341
|
+
]);
|
|
342
|
+
// Combine default sensitive headers with user-specified excluded headers
|
|
343
|
+
const excludedHeaders = new Set([
|
|
344
|
+
...defaultSensitiveHeaders,
|
|
345
|
+
...this.networkSettings.excludeHeaders.map(h => h.toLowerCase())
|
|
346
|
+
]);
|
|
347
|
+
for (const [key, value] of Object.entries(headers)) {
|
|
348
|
+
if (excludedHeaders.has(key.toLowerCase())) {
|
|
349
|
+
// Don't record excluded headers
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
filtered[key] = value;
|
|
353
|
+
}
|
|
354
|
+
return filtered;
|
|
355
|
+
}
|
|
356
|
+
truncateContent(content, maxSize) {
|
|
357
|
+
if (!content)
|
|
358
|
+
return content;
|
|
359
|
+
// Apply masking function if provided
|
|
360
|
+
let processedContent = content;
|
|
361
|
+
if (this.networkSettings.requestBodyMaskingFunction) {
|
|
362
|
+
processedContent = this.networkSettings.requestBodyMaskingFunction(content);
|
|
363
|
+
}
|
|
364
|
+
if (processedContent.length > maxSize) {
|
|
365
|
+
return processedContent.substring(0, maxSize) + `... [truncated from ${processedContent.length} chars]`;
|
|
366
|
+
}
|
|
367
|
+
return processedContent;
|
|
368
|
+
}
|
|
369
|
+
generateRequestId() {
|
|
370
|
+
return `req_${crypto.randomUUID()}`;
|
|
371
|
+
}
|
|
372
|
+
addCompletedRequest(requestId, request) {
|
|
373
|
+
this.pendingRequests.delete(requestId);
|
|
374
|
+
this.requestsBuffer.push(request);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
@@ -1,33 +1,43 @@
|
|
|
1
1
|
export declare class Recorder {
|
|
2
2
|
private window;
|
|
3
3
|
private publicToken;
|
|
4
|
-
private recorderSettings;
|
|
5
4
|
private sessionRecorder;
|
|
6
5
|
private eventRecorder;
|
|
6
|
+
private errorRecorder;
|
|
7
|
+
private networkRecorder;
|
|
8
|
+
private recorderSettings;
|
|
7
9
|
private capturedSessionId;
|
|
8
10
|
private pingIntervalMs;
|
|
9
11
|
private pingTimeout;
|
|
10
|
-
constructor(window: Window, publicToken: string,
|
|
12
|
+
constructor(window: Window, publicToken: string, userSettings?: Partial<RecorderSettings>);
|
|
11
13
|
private schedulePing;
|
|
12
14
|
private ping;
|
|
13
15
|
/**
|
|
14
|
-
* Start
|
|
16
|
+
* Start all recorders
|
|
15
17
|
*/
|
|
16
18
|
start(): void;
|
|
17
19
|
/**
|
|
18
|
-
* Stop
|
|
20
|
+
* Stop all recorders
|
|
19
21
|
*/
|
|
20
22
|
stop(): void;
|
|
21
23
|
private collectCapturedUserMetadata;
|
|
22
24
|
}
|
|
23
25
|
export interface RecorderSettings {
|
|
24
|
-
maskingLevel:
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
maskingLevel: "none" | "all" | "input-and-textarea" | "input-password-or-email-and-textarea";
|
|
27
|
+
consoleRecording: {
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
};
|
|
30
|
+
networkRecording: {
|
|
31
|
+
enabled: boolean;
|
|
32
|
+
maxRequestBodySize: number;
|
|
33
|
+
maxResponseBodySize: number;
|
|
34
|
+
excludeDomains: string[];
|
|
35
|
+
captureHeaders: boolean;
|
|
36
|
+
captureRequestBodies: boolean;
|
|
37
|
+
captureResponseBodies: boolean;
|
|
38
|
+
excludeHeaders: string[];
|
|
39
|
+
requestBodyMaskingFunction?: (body: string) => string;
|
|
40
|
+
};
|
|
31
41
|
}
|
|
32
42
|
export interface CapturedUserMetadata {
|
|
33
43
|
browserName?: string;
|
|
@@ -1,31 +1,63 @@
|
|
|
1
1
|
import { SessionRecorder } from "./sessionRecorder";
|
|
2
2
|
import { EventRecorder } from "./eventRecorder";
|
|
3
|
+
import { ErrorRecorder } from "./errorRecorder";
|
|
4
|
+
import { NetworkRecorder } from "./networkRecorder";
|
|
3
5
|
import { post, put } from "../requests";
|
|
4
6
|
import { UAParser } from "ua-parser-js";
|
|
5
7
|
export class Recorder {
|
|
6
8
|
window;
|
|
7
9
|
publicToken;
|
|
8
|
-
recorderSettings;
|
|
9
10
|
sessionRecorder;
|
|
10
11
|
eventRecorder;
|
|
12
|
+
errorRecorder;
|
|
13
|
+
networkRecorder;
|
|
14
|
+
recorderSettings;
|
|
11
15
|
capturedSessionId = null;
|
|
12
16
|
pingIntervalMs = 20000;
|
|
13
17
|
pingTimeout = null;
|
|
14
|
-
constructor(window, publicToken,
|
|
18
|
+
constructor(window, publicToken, userSettings = {}) {
|
|
15
19
|
this.window = window;
|
|
16
20
|
this.publicToken = publicToken;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
// Default settings
|
|
22
|
+
const defaultSettings = {
|
|
23
|
+
maskingLevel: "all",
|
|
24
|
+
consoleRecording: { enabled: false },
|
|
25
|
+
networkRecording: {
|
|
26
|
+
enabled: false,
|
|
27
|
+
maxRequestBodySize: 10 * 1024,
|
|
28
|
+
maxResponseBodySize: 50 * 1024,
|
|
29
|
+
excludeDomains: [],
|
|
30
|
+
captureHeaders: true,
|
|
31
|
+
captureRequestBodies: true,
|
|
32
|
+
captureResponseBodies: true,
|
|
33
|
+
excludeHeaders: []
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
// Merge user settings with defaults
|
|
37
|
+
this.recorderSettings = {
|
|
38
|
+
...defaultSettings,
|
|
39
|
+
...userSettings,
|
|
40
|
+
consoleRecording: {
|
|
41
|
+
...defaultSettings.consoleRecording,
|
|
42
|
+
...(userSettings.consoleRecording || {})
|
|
43
|
+
},
|
|
44
|
+
networkRecording: {
|
|
45
|
+
...defaultSettings.networkRecording,
|
|
46
|
+
...(userSettings.networkRecording || {})
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
this.sessionRecorder = new SessionRecorder(this.recorderSettings);
|
|
50
|
+
this.eventRecorder = new EventRecorder(window, this.recorderSettings);
|
|
51
|
+
this.errorRecorder = new ErrorRecorder(window, this.recorderSettings.consoleRecording);
|
|
52
|
+
this.networkRecorder = new NetworkRecorder(window, this.recorderSettings.networkRecording);
|
|
23
53
|
post(`public/captured-sessions`, { publicToken }, { withCredentials: false })
|
|
24
54
|
.then(response => {
|
|
25
55
|
const id = response.data;
|
|
26
56
|
this.capturedSessionId = id;
|
|
27
57
|
this.sessionRecorder.setCapturedSessionId(id);
|
|
28
58
|
this.eventRecorder.setCapturedSessionId(id);
|
|
59
|
+
this.errorRecorder.setCapturedSessionId(id);
|
|
60
|
+
this.networkRecorder.setCapturedSessionId(id);
|
|
29
61
|
this.schedulePing();
|
|
30
62
|
const capturedUserMetadata = this.collectCapturedUserMetadata();
|
|
31
63
|
post(`public/captured-sessions/${this.capturedSessionId}/captured-session/metadata`, capturedUserMetadata, { withCredentials: false });
|
|
@@ -34,6 +66,8 @@ export class Recorder {
|
|
|
34
66
|
console.error(error);
|
|
35
67
|
this.sessionRecorder.stop();
|
|
36
68
|
this.eventRecorder.stop();
|
|
69
|
+
this.errorRecorder.stop();
|
|
70
|
+
this.networkRecorder.stop();
|
|
37
71
|
});
|
|
38
72
|
}
|
|
39
73
|
schedulePing() {
|
|
@@ -47,18 +81,22 @@ export class Recorder {
|
|
|
47
81
|
this.schedulePing();
|
|
48
82
|
};
|
|
49
83
|
/**
|
|
50
|
-
* Start
|
|
84
|
+
* Start all recorders
|
|
51
85
|
*/
|
|
52
86
|
start() {
|
|
53
87
|
this.sessionRecorder.start();
|
|
54
88
|
this.eventRecorder.start();
|
|
89
|
+
this.errorRecorder.start();
|
|
90
|
+
this.networkRecorder.start();
|
|
55
91
|
}
|
|
56
92
|
/**
|
|
57
|
-
* Stop
|
|
93
|
+
* Stop all recorders
|
|
58
94
|
*/
|
|
59
95
|
stop() {
|
|
60
96
|
this.sessionRecorder.stop();
|
|
61
97
|
this.eventRecorder.stop();
|
|
98
|
+
this.errorRecorder.stop();
|
|
99
|
+
this.networkRecorder.stop();
|
|
62
100
|
}
|
|
63
101
|
collectCapturedUserMetadata = () => {
|
|
64
102
|
const ua = new UAParser();
|
|
@@ -105,10 +143,3 @@ export class Recorder {
|
|
|
105
143
|
};
|
|
106
144
|
};
|
|
107
145
|
}
|
|
108
|
-
export var MaskingLevel;
|
|
109
|
-
(function (MaskingLevel) {
|
|
110
|
-
MaskingLevel["None"] = "none";
|
|
111
|
-
MaskingLevel["All"] = "all";
|
|
112
|
-
MaskingLevel["InputAndTextArea"] = "input-and-textarea";
|
|
113
|
-
MaskingLevel["InputPasswordOrEmailAndTextArea"] = "input-password-or-email-and-textarea";
|
|
114
|
-
})(MaskingLevel || (MaskingLevel = {}));
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { record } from "rrweb";
|
|
2
2
|
import { patch } from "../requests";
|
|
3
3
|
import { logger } from "../logger";
|
|
4
|
-
import {
|
|
5
|
-
import { assertNever } from "../utils";
|
|
4
|
+
import {} from "./recorder";
|
|
6
5
|
export class SessionRecorder {
|
|
7
6
|
recorderSettings;
|
|
8
7
|
capturedSessionId = null;
|
|
@@ -35,20 +34,18 @@ export class SessionRecorder {
|
|
|
35
34
|
};
|
|
36
35
|
const maskFn = (input) => input.replaceAll(/\S/g, "*");
|
|
37
36
|
switch (this.recorderSettings.maskingLevel) {
|
|
38
|
-
case
|
|
37
|
+
case "none":
|
|
39
38
|
break;
|
|
40
|
-
case
|
|
39
|
+
case "all":
|
|
41
40
|
recordOptions.maskTextFn = maskFn;
|
|
42
41
|
recordOptions.maskTextSelector = "*";
|
|
43
42
|
recordOptions.maskAllInputs = true;
|
|
44
43
|
recordOptions.maskInputFn = maskFn;
|
|
45
44
|
break;
|
|
46
|
-
case
|
|
47
|
-
case MaskingLevel.InputAndTextArea:
|
|
48
|
-
logger.error("masking level: input and textarea");
|
|
45
|
+
case "input-and-textarea":
|
|
49
46
|
recordOptions.maskAllInputs = true;
|
|
50
47
|
break;
|
|
51
|
-
case
|
|
48
|
+
case "input-password-or-email-and-textarea":
|
|
52
49
|
recordOptions.maskInputOptions = {
|
|
53
50
|
password: true,
|
|
54
51
|
email: true,
|
|
@@ -56,7 +53,10 @@ export class SessionRecorder {
|
|
|
56
53
|
};
|
|
57
54
|
break;
|
|
58
55
|
default:
|
|
59
|
-
|
|
56
|
+
recordOptions.maskTextFn = maskFn;
|
|
57
|
+
recordOptions.maskTextSelector = "*";
|
|
58
|
+
recordOptions.maskAllInputs = true;
|
|
59
|
+
recordOptions.maskInputFn = maskFn;
|
|
60
60
|
}
|
|
61
61
|
this.stopFn = record(recordOptions);
|
|
62
62
|
this.scheduleFlush();
|