@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.
@@ -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, recorderSettings: RecorderSettings);
12
+ constructor(window: Window, publicToken: string, userSettings?: Partial<RecorderSettings>);
11
13
  private schedulePing;
12
14
  private ping;
13
15
  /**
14
- * Start both recorders
16
+ * Start all recorders
15
17
  */
16
18
  start(): void;
17
19
  /**
18
- * Stop both recorders
20
+ * Stop all recorders
19
21
  */
20
22
  stop(): void;
21
23
  private collectCapturedUserMetadata;
22
24
  }
23
25
  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"
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, recorderSettings) {
18
+ constructor(window, publicToken, userSettings = {}) {
15
19
  this.window = window;
16
20
  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);
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 both recorders
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 both recorders
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 { MaskingLevel } from "./recorder";
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 MaskingLevel.None:
37
+ case "none":
39
38
  break;
40
- case MaskingLevel.All:
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 null:
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 MaskingLevel.InputPasswordOrEmailAndTextArea:
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
- assertNever(this.recorderSettings.maskingLevel);
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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@affogatosoftware/recorder",
3
- "version": "1.0.8",
3
+ "version": "1.1.0",
4
4
  "license": "MIT",
5
5
  "author": "Chris Ryan",
6
6
  "main": "./dist/index.js",