@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/browser/recorder.iife.js +3 -1
- package/dist/browser/recorder.iife.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/recorder/errorRecorder.d.ts +29 -0
- package/dist/recorder/errorRecorder.js +143 -0
- package/dist/recorder/networkRecorder.d.ts +54 -0
- package/dist/recorder/networkRecorder.js +376 -0
- package/dist/recorder/recorder.d.ts +21 -5
- package/dist/recorder/recorder.js +48 -10
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
|
@@ -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,
|
|
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
|
|
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;
|