@perceptr/web-sdk
Version:
Perceptr Web SDK for recording and monitoring user sessions
354 lines (353 loc) • 14 kB
JavaScript
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import { v4 as uuidv4 } from "uuid";
import { logger } from "./utils/logger";
export class NetworkMonitor {
constructor(config = {}, startTime) {
this.requests = [];
this.isEnabled = false;
this.config = {
maxRequests: 1000,
sanitizeHeaders: ["authorization", "cookie", "x-auth-token"],
sanitizeParams: [
"password",
"token",
"secret",
"key",
"apikey",
"api_key",
"access_token",
],
sanitizeBodyFields: [
"password",
"token",
"secret",
"key",
"apikey",
"api_key",
"access_token",
"credit_card",
"creditCard",
"cvv",
"ssn",
],
captureRequestBody: true,
captureResponseBody: true,
maxBodySize: 100 * 1024, // 100KB default
excludeUrls: [/\/logs$/, /\/health$/],
};
this.startTime = startTime;
this.config = Object.assign(Object.assign({}, this.config), config);
this.originalFetch = window.fetch;
this.originalXHROpen = XMLHttpRequest.prototype.open;
this.originalXHRSend = XMLHttpRequest.prototype.send;
}
enable() {
if (this.isEnabled) {
logger.warn("NetworkMonitor already enabled");
return;
}
this.patchFetch();
this.patchXHR();
this.isEnabled = true;
}
disable() {
if (!this.isEnabled) {
logger.warn("NetworkMonitor already disabled");
return;
}
window.fetch = this.originalFetch;
XMLHttpRequest.prototype.open = this.originalXHROpen;
XMLHttpRequest.prototype.send = this.originalXHRSend;
this.isEnabled = false;
}
getRequests() {
return this.requests;
}
clearRequests() {
this.requests = [];
}
shouldCaptureUrl(url) {
return (!this.config.excludeUrls.some((pattern) => pattern.test(url)) &&
!url.includes("/per/"));
}
sanitizeUrl(url) {
try {
const urlObj = new URL(url);
const params = new URLSearchParams(urlObj.search);
let sanitized = false;
for (const [key] of params.entries()) {
if (this.shouldSanitizeParam(key)) {
params.set(key, "[REDACTED]");
sanitized = true;
}
}
if (sanitized) {
urlObj.search = params.toString();
return urlObj.toString();
}
}
catch (e) {
// If URL parsing fails, return the original URL
}
return url;
}
shouldSanitizeParam(param) {
return this.config.sanitizeParams.some((pattern) => param.toLowerCase().includes(pattern.toLowerCase()));
}
sanitizeHeaders(headers) {
const sanitized = Object.assign({}, headers);
for (const key of Object.keys(sanitized)) {
if (this.config.sanitizeHeaders.includes(key.toLowerCase())) {
sanitized[key] = "[REDACTED]";
}
}
return sanitized;
}
sanitizeBody(body) {
if (!body)
return body;
// Handle string bodies (try to parse as JSON)
if (typeof body === "string") {
try {
const parsed = JSON.parse(body);
return JSON.stringify(this.sanitizeObjectBody(parsed));
}
catch (e) {
// Not JSON, return as is or truncate if too large
return this.truncateBody(body);
}
}
// Handle FormData
if (body instanceof FormData) {
const sanitized = new FormData();
for (const [key, value] of body.entries()) {
if (this.shouldSanitizeBodyField(key)) {
sanitized.append(key, "[REDACTED]");
}
else {
sanitized.append(key, value);
}
}
return sanitized;
}
// Handle URLSearchParams
if (body instanceof URLSearchParams) {
const sanitized = new URLSearchParams();
for (const [key, value] of body.entries()) {
if (this.shouldSanitizeBodyField(key)) {
sanitized.append(key, "[REDACTED]");
}
else {
sanitized.append(key, value);
}
}
return sanitized;
}
// Handle plain objects
if (typeof body === "object" && body !== null) {
return this.sanitizeObjectBody(body);
}
return this.truncateBody(body);
}
sanitizeObjectBody(obj) {
if (Array.isArray(obj)) {
return obj.map((item) => this.sanitizeObjectBody(item));
}
if (typeof obj === "object" && obj !== null) {
const result = Object.assign({}, obj);
for (const key in result) {
if (this.shouldSanitizeBodyField(key)) {
result[key] = "[REDACTED]";
}
else if (typeof result[key] === "object" && result[key] !== null) {
result[key] = this.sanitizeObjectBody(result[key]);
}
}
return result;
}
return obj;
}
shouldSanitizeBodyField(field) {
return this.config.sanitizeBodyFields.some((pattern) => field.toLowerCase().includes(pattern.toLowerCase()));
}
truncateBody(body) {
if (typeof body === "string" && body.length > this.config.maxBodySize) {
return body.substring(0, this.config.maxBodySize) + "... [truncated]";
}
return body;
}
addRequest(request) {
this.requests.push(request);
if (this.requests.length > this.config.maxRequests) {
this.requests.shift();
}
}
getVideoTimestamp(timestamp) {
const seconds = Math.floor((timestamp - this.startTime) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const remainingSeconds = seconds % 60;
return `${hours.toString().padStart(2, "0")}:${minutes
.toString()
.padStart(2, "0")}:${remainingSeconds.toString().padStart(2, "0")}`;
}
patchFetch() {
window.fetch = (input, init) => __awaiter(this, void 0, void 0, function* () {
if (!this.shouldCaptureUrl(input.toString())) {
return this.originalFetch(input, init);
}
const startRequestTime = Date.now();
const requestId = uuidv4();
let requestBody = undefined;
// Capture and sanitize request body if enabled
if (this.config.captureRequestBody && (init === null || init === void 0 ? void 0 : init.body)) {
requestBody = this.sanitizeBody(init.body);
}
try {
const response = yield this.originalFetch(input, init);
const duration = Date.now() - startRequestTime;
const requestHeaders = (init === null || init === void 0 ? void 0 : init.headers)
? this.sanitizeHeaders(Object.fromEntries(new Headers(init.headers).entries()))
: {};
const responseHeaders = this.sanitizeHeaders(Object.fromEntries(response.headers.entries()));
const request = {
type: 7,
id: requestId,
timestamp: startRequestTime,
video_timestamp: this.getVideoTimestamp(startRequestTime),
duration,
method: (init === null || init === void 0 ? void 0 : init.method) || "GET",
url: this.sanitizeUrl(input.toString()),
status: response.status,
statusText: response.statusText,
requestHeaders,
responseHeaders,
};
if (requestBody) {
request.requestBody = requestBody;
}
if (this.config.captureResponseBody) {
const clonedResponse = response.clone();
try {
const responseText = yield clonedResponse.text();
request.responseBody = this.sanitizeBody(responseText);
}
catch (e) {
// Ignore response body capture errors
}
}
this.addRequest(request);
return response;
}
catch (error) {
const duration = Date.now() - startRequestTime;
this.addRequest({
type: 7,
id: requestId,
timestamp: startRequestTime,
video_timestamp: this.getVideoTimestamp(startRequestTime),
duration,
method: (init === null || init === void 0 ? void 0 : init.method) || "GET",
url: this.sanitizeUrl(input.toString()),
requestHeaders: (init === null || init === void 0 ? void 0 : init.headers)
? this.sanitizeHeaders(Object.fromEntries(new Headers(init.headers).entries()))
: {},
responseHeaders: {},
requestBody,
error,
});
throw error;
}
});
}
patchXHR() {
const self = this; // Store reference to class instance
XMLHttpRequest.prototype.open = function (method, url, ...args) {
this.__requestData = {
id: uuidv4(),
method,
url,
startTime: Date.now(),
};
return self.originalXHROpen.apply(this, [method, url, ...args]);
};
XMLHttpRequest.prototype.send = function (body) {
if (!this.__requestData ||
!self.shouldCaptureUrl(this.__requestData.url)) {
return self.originalXHRSend.call(this, body);
}
const requestData = this.__requestData;
let sanitizedBody = undefined;
// Capture and sanitize request body if enabled
if (self.config.captureRequestBody && body) {
sanitizedBody = self.sanitizeBody(body);
}
this.addEventListener("load", function () {
const duration = Date.now() - requestData.startTime;
const request = {
type: 7,
id: requestData.id,
timestamp: requestData.startTime,
video_timestamp: self.getVideoTimestamp(requestData.startTime),
duration,
method: requestData.method,
url: self.sanitizeUrl(requestData.url),
status: this.status,
statusText: this.statusText,
requestHeaders: self.sanitizeHeaders(this.getAllResponseHeaders()
.split("\r\n")
.reduce((acc, line) => {
const [key, value] = line.split(": ");
if (key && value)
acc[key] = value;
return acc;
}, {})),
responseHeaders: {},
};
if (sanitizedBody) {
request.requestBody = sanitizedBody;
}
if (self.config.captureResponseBody) {
request.responseBody = self.sanitizeBody(this.responseText);
}
self.addRequest(request);
});
this.addEventListener("error", function () {
const duration = Date.now() - requestData.startTime;
self.addRequest({
type: 7,
id: requestData.id,
timestamp: requestData.startTime,
video_timestamp: self.getVideoTimestamp(requestData.startTime),
duration,
method: requestData.method,
url: self.sanitizeUrl(requestData.url),
requestHeaders: {},
responseHeaders: {},
requestBody: sanitizedBody,
error: "Network error",
});
});
return self.originalXHRSend.call(this, body);
};
}
onRequest(callback) {
const originalAddRequest = this.addRequest;
this.addRequest = (request) => {
originalAddRequest.call(this, request);
callback(request);
};
// Return a function to unsubscribe
return () => {
this.addRequest = originalAddRequest;
};
}
}