@affogatosoftware/recorder 1.0.9 → 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/index.d.ts CHANGED
@@ -1 +1,2 @@
1
1
  export { Recorder, type RecorderSettings } from './recorder/recorder';
2
+ export { NetworkRecorder, type NetworkRequest } from './recorder/networkRecorder';
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
1
  export { Recorder } from './recorder/recorder';
2
+ export { NetworkRecorder } from './recorder/networkRecorder';
@@ -0,0 +1,29 @@
1
+ export interface ConsoleError {
2
+ errorType: "console_error" | "console_warn" | "uncaught_error" | "unhandled_rejection";
3
+ message: string;
4
+ stack?: string;
5
+ timestamp: number;
6
+ source?: string;
7
+ }
8
+ export declare class ErrorRecorder {
9
+ private window;
10
+ private errorsBuffer;
11
+ private isRunning;
12
+ private readonly flushIntervalMs;
13
+ private capturedSessionId;
14
+ private flushTimerId;
15
+ private originalConsoleError;
16
+ private originalConsoleWarn;
17
+ private enabled;
18
+ constructor(window: Window, consoleErrorSettings?: {
19
+ enabled: boolean;
20
+ });
21
+ start: () => void;
22
+ stop: () => void;
23
+ private captureConsoleError;
24
+ private handleUncaughtError;
25
+ private handleUnhandledRejection;
26
+ private scheduleFlush;
27
+ private flush;
28
+ setCapturedSessionId(uuid: string): void;
29
+ }
@@ -0,0 +1,143 @@
1
+ // Captures console errors, warnings, and uncaught errors/promise rejections
2
+ // Buffers error events and flushes to API endpoint periodically similar to EventRecorder
3
+ import { logger } from "../logger";
4
+ import { post } from "../requests";
5
+ export class ErrorRecorder {
6
+ window;
7
+ errorsBuffer = [];
8
+ isRunning = false;
9
+ flushIntervalMs = 2000;
10
+ capturedSessionId;
11
+ flushTimerId = null;
12
+ originalConsoleError;
13
+ originalConsoleWarn;
14
+ enabled;
15
+ constructor(window, consoleErrorSettings) {
16
+ this.window = window;
17
+ this.originalConsoleError = console.error.bind(console);
18
+ this.originalConsoleWarn = console.warn.bind(console);
19
+ this.enabled = consoleErrorSettings?.enabled ?? false; // Default enabled for backwards compatibility
20
+ }
21
+ start = () => {
22
+ if (this.isRunning || !this.enabled) {
23
+ return;
24
+ }
25
+ this.isRunning = true;
26
+ this.errorsBuffer = [];
27
+ // Intercept console.error
28
+ console.error = (...args) => {
29
+ this.captureConsoleError("console_error", args);
30
+ this.originalConsoleError(...args);
31
+ };
32
+ // Intercept console.warn
33
+ console.warn = (...args) => {
34
+ this.captureConsoleError("console_warn", args);
35
+ this.originalConsoleWarn(...args);
36
+ };
37
+ // Capture uncaught errors
38
+ this.window.addEventListener("error", this.handleUncaughtError);
39
+ // Capture unhandled promise rejections
40
+ this.window.addEventListener("unhandledrejection", this.handleUnhandledRejection);
41
+ this.scheduleFlush();
42
+ };
43
+ stop = () => {
44
+ this.isRunning = false;
45
+ // Restore original console methods
46
+ console.error = this.originalConsoleError;
47
+ console.warn = this.originalConsoleWarn;
48
+ // Remove event listeners
49
+ this.window.removeEventListener("error", this.handleUncaughtError);
50
+ this.window.removeEventListener("unhandledrejection", this.handleUnhandledRejection);
51
+ if (this.flushTimerId) {
52
+ clearTimeout(this.flushTimerId);
53
+ this.flushTimerId = null;
54
+ }
55
+ };
56
+ captureConsoleError = (errorType, args) => {
57
+ if (!this.isRunning) {
58
+ return;
59
+ }
60
+ try {
61
+ const message = args.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)).join(' ');
62
+ const error = {
63
+ errorType,
64
+ message,
65
+ timestamp: Date.now()
66
+ };
67
+ // Try to extract stack trace if available
68
+ if (args.length > 0 && args[0] instanceof Error) {
69
+ error.stack = args[0].stack;
70
+ }
71
+ this.errorsBuffer.push(error);
72
+ }
73
+ catch (captureError) {
74
+ logger.error("Failed to capture console error", captureError);
75
+ }
76
+ };
77
+ handleUncaughtError = (event) => {
78
+ if (!this.isRunning) {
79
+ return;
80
+ }
81
+ try {
82
+ const error = {
83
+ errorType: "uncaught_error",
84
+ message: event.message,
85
+ stack: event.error?.stack,
86
+ timestamp: Date.now(),
87
+ source: event.filename ? `${event.filename}:${event.lineno}:${event.colno}` : undefined
88
+ };
89
+ this.errorsBuffer.push(error);
90
+ }
91
+ catch (captureError) {
92
+ logger.error("Failed to capture uncaught error", captureError);
93
+ }
94
+ };
95
+ handleUnhandledRejection = (event) => {
96
+ if (!this.isRunning) {
97
+ return;
98
+ }
99
+ try {
100
+ let message;
101
+ let stack;
102
+ if (event.reason instanceof Error) {
103
+ message = event.reason.message;
104
+ stack = event.reason.stack;
105
+ }
106
+ else {
107
+ message = String(event.reason);
108
+ }
109
+ const error = {
110
+ errorType: "unhandled_rejection",
111
+ message,
112
+ stack,
113
+ timestamp: Date.now()
114
+ };
115
+ this.errorsBuffer.push(error);
116
+ }
117
+ catch (captureError) {
118
+ logger.error("Failed to capture unhandled rejection", captureError);
119
+ }
120
+ };
121
+ scheduleFlush = () => {
122
+ if (this.flushTimerId) {
123
+ clearTimeout(this.flushTimerId);
124
+ }
125
+ this.flushTimerId = setTimeout(this.flush, this.flushIntervalMs);
126
+ };
127
+ flush = async () => {
128
+ if (this.errorsBuffer.length > 0 && this.capturedSessionId) {
129
+ try {
130
+ const response = await post(`/public/captured-sessions/${this.capturedSessionId}/console-errors`, this.errorsBuffer, { withCredentials: false });
131
+ this.errorsBuffer = [];
132
+ }
133
+ catch (error) {
134
+ logger.error("Failed to flush console errors", error);
135
+ this.errorsBuffer = [];
136
+ }
137
+ }
138
+ this.scheduleFlush();
139
+ };
140
+ setCapturedSessionId(uuid) {
141
+ this.capturedSessionId = uuid;
142
+ }
143
+ }
@@ -0,0 +1,54 @@
1
+ export interface NetworkRequest {
2
+ requestId: string;
3
+ type: "xhr" | "fetch";
4
+ method: string;
5
+ url: string;
6
+ requestHeaders?: Record<string, string>;
7
+ requestBody?: string;
8
+ responseStatus?: number;
9
+ responseHeaders?: Record<string, string>;
10
+ responseBody?: string;
11
+ timestamp: number;
12
+ duration?: number;
13
+ error?: string;
14
+ }
15
+ interface NetworkRecorderSettings {
16
+ enabled: boolean;
17
+ maxRequestBodySize: number;
18
+ maxResponseBodySize: number;
19
+ excludeDomains: string[];
20
+ captureHeaders: boolean;
21
+ captureRequestBodies: boolean;
22
+ captureResponseBodies: boolean;
23
+ excludeHeaders: string[];
24
+ requestBodyMaskingFunction?: (body: string) => string;
25
+ }
26
+ export declare class NetworkRecorder {
27
+ private window;
28
+ private requestsBuffer;
29
+ private pendingRequests;
30
+ private isRunning;
31
+ private readonly flushIntervalMs;
32
+ private capturedSessionId;
33
+ private flushTimerId;
34
+ private originalFetch;
35
+ private originalXHROpen;
36
+ private originalXHRSend;
37
+ private queryParamsAllowed;
38
+ private networkSettings;
39
+ constructor(window: Window, settings?: Partial<NetworkRecorderSettings>);
40
+ start: () => void;
41
+ stop: () => void;
42
+ setCapturedSessionId(uuid: string): void;
43
+ private scheduleFlush;
44
+ private flush;
45
+ private patchFetch;
46
+ private patchXHR;
47
+ private shouldCaptureRequest;
48
+ private sanitizeUrl;
49
+ private filterHeaders;
50
+ private truncateContent;
51
+ private generateRequestId;
52
+ private addCompletedRequest;
53
+ }
54
+ export {};
@@ -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,27 +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?: "none" | "all" | "input-and-textarea" | "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
+ };
25
41
  }
26
42
  export interface CapturedUserMetadata {
27
43
  browserName?: string;