UNPKG

@perceptr/web-sdk

Version:

Perceptr Web SDK for recording and monitoring user sessions

354 lines (353 loc) 14 kB
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; }; } }