UNPKG

@puberty-labs/clits

Version:

CLiTS (Chrome Logging and Inspection Tool Suite) is a powerful Node.js library for automated Chrome browser testing, logging, and inspection. It provides a comprehensive suite of tools for monitoring network requests, console logs, DOM mutations, and more

975 lines 78 kB
// BSD: Chrome DevTools Protocol integration for extracting logs, network requests, and console output from Chrome browser sessions. // Provides advanced filtering, formatting, and error handling capabilities for browser-based debugging. import CDP from 'chrome-remote-interface'; import { createLogger, format, transports } from 'winston'; import fetch from 'node-fetch'; import { PlatformErrorHandler } from './platform/error-handler.js'; import { ChromeErrorHandler } from './platform/chrome-error-handler.js'; import { REACT_HOOK_MONITOR_SCRIPT } from './utils/react-hook-monitor.js'; import { Buffer } from 'buffer'; import { REDUX_STATE_MONITOR_SCRIPT } from './utils/redux-state-monitor.js'; import { EVENT_LOOP_MONITOR_SCRIPT } from './utils/event-loop-monitor.js'; import { USER_INTERACTION_MONITOR_SCRIPT } from './utils/user-interaction-monitor.js'; import { DOM_MUTATION_MONITOR_SCRIPT } from './utils/dom-mutation-monitor.js'; import { DataSanitizer } from './utils/data-sanitizer.js'; const logger = createLogger({ level: 'info', format: format.combine(format.timestamp(), format.json()), transports: [ new transports.Console({ format: format.combine(format.colorize(), format.simple()) }) ] }); export class ChromeExtractor { constructor(options = {}) { this.pendingNetworkRequests = new Map(); // Added for correlation this.pendingGraphqlRequests = new Map(); // Added for GraphQL correlation this.collectedLogs = []; // Initialize collectedLogs array this.options = { port: options.port || ChromeExtractor.DEFAULT_PORT, host: options.host || ChromeExtractor.DEFAULT_HOST, maxEntries: options.maxEntries || ChromeExtractor.DEFAULT_MAX_ENTRIES, includeNetwork: options.includeNetwork ?? true, includeConsole: options.includeConsole ?? true, retryConfig: options.retryConfig || {}, reconnect: { enabled: options.reconnect?.enabled ?? true, maxAttempts: options.reconnect?.maxAttempts ?? 5, delayBetweenAttemptsMs: options.reconnect?.delayBetweenAttemptsMs ?? 2000 }, filters: { logLevels: options.filters?.logLevels || ['error', 'warning', 'info', 'debug', 'log'], sources: options.filters?.sources || ['network', 'console', 'devtools', 'websocket', 'jwt', 'graphql', 'redux', 'performance', 'eventloop', 'userinteraction', 'dommutation', 'csschange', 'reacthook'], domains: options.filters?.domains || [], keywords: options.filters?.keywords || [], excludePatterns: options.filters?.excludePatterns || [], advancedFilter: options.filters?.advancedFilter || '' }, format: { groupBySource: options.format?.groupBySource ?? false, groupByLevel: options.format?.groupByLevel ?? false, includeTimestamp: options.format?.includeTimestamp ?? true, includeStackTrace: options.format?.includeStackTrace ?? true }, enableReactHookMonitoring: options.enableReactHookMonitoring ?? false, includeWebSockets: options.includeWebSockets ?? false, includeJwtMonitoring: options.includeJwtMonitoring ?? false, includeGraphqlMonitoring: options.includeGraphqlMonitoring ?? false, includeReduxMonitoring: options.includeReduxMonitoring ?? false, includePerformanceMonitoring: options.includePerformanceMonitoring ?? false, includeEventLoopMonitoring: options.includeEventLoopMonitoring ?? false, includeUserInteractionRecording: options.includeUserInteractionRecording ?? false, includeDomMutationMonitoring: options.includeDomMutationMonitoring ?? false, includeCssChangeMonitoring: options.includeCssChangeMonitoring ?? false, headless: options.headless ?? false, sanitizationRules: options.sanitizationRules ?? [] }; this.chromeErrorHandler = new ChromeErrorHandler(this.options.retryConfig); this.dataSanitizer = new DataSanitizer(this.options.sanitizationRules); } async getDebuggablePageTargets() { return this.chromeErrorHandler.executeWithRetry(async () => { try { const versionResponse = await fetch(`http://${this.options.host}:${this.options.port}/json/version`); if (!versionResponse.ok) { throw new Error('Chrome is not running with remote debugging enabled. Start Chrome with --remote-debugging-port=9222'); } const response = await fetch(`http://${this.options.host}:${this.options.port}/json/list`); const targets = await response.json(); logger.info('Available targets', { targets: targets.map(t => ({ type: t.type, url: t.url, title: t.title })) }); const pageTargets = targets.filter(t => t.type === 'page' && t.webSocketDebuggerUrl).map(t => ({ id: t.id, url: t.url, title: t.title, webSocketDebuggerUrl: t.webSocketDebuggerUrl, })); if (pageTargets.length === 0) { logger.warn('No debuggable page targets found. Please open a new tab in Chrome.'); throw new Error('No debuggable Chrome tab found. Please open a new tab in Chrome running with --remote-debugging-port=9222'); } return pageTargets; } catch (error) { if (error instanceof Error) { throw error; } logger.error('Failed to get Chrome debugger targets', { error }); throw new Error(`Failed to connect to Chrome debugger: ${error instanceof Error ? error.message : String(error)}`); } }, 'Getting Chrome debugger targets'); } // This method is now private and should be called with a specific debugger URL async connectToDebugger(debuggerUrl) { const cdpOptions = { target: debuggerUrl }; if (this.options.headless) { cdpOptions.port = this.options.port; cdpOptions.host = this.options.host; } return this.chromeErrorHandler.executeWithRetry(async () => CDP(cdpOptions), 'Connecting to Chrome DevTools'); } shouldIncludeLog(log) { const filters = this.options.filters; // Apply advanced filter if provided if (filters.advancedFilter && filters.advancedFilter.trim() !== '') { return this.evaluateAdvancedFilter(filters.advancedFilter, log); } // Otherwise apply standard filters // Check log level if (log.type === 'console' || log.type === 'log') { const details = log.details; // Handle nested message structure for console logs let level; if (details?.message && typeof details.message.level === 'string') { // Console logs have nested message structure level = details.message.level.toLowerCase(); } else if (details && typeof details.level === 'string') { // DevTools logs have level directly on details level = details.level.toLowerCase(); } else { logger.debug('Log entry missing level property, defaulting to "log"', { logType: log.type, details }); level = 'log'; // Default to 'log' level instead of rejecting } // Map log levels to standardized values let logLevel; switch (level) { case 'log': case 'info': logLevel = 'log'; // Map both 'log' and 'info' to 'log' for consistency break; case 'warn': case 'warning': logLevel = 'warning'; break; case 'error': logLevel = 'error'; break; case 'debug': logLevel = 'debug'; break; default: // For unknown levels, default to 'log' to avoid filtering out valid entries logger.debug('Unknown log level, defaulting to "log"', { level, log }); logLevel = 'log'; break; } if (!filters.logLevels?.includes(logLevel)) { return false; } } // Check source // Map log types to source categories for filtering let source; if (log.type === 'log') { source = 'devtools'; // Map 'log' type to 'devtools' source } else if (log.type === 'credential') { source = 'console'; // Map 'credential' type to 'console' source } else { source = log.type; } if (!filters.sources?.includes(source)) { return false; } // Check domains for network requests (and now correlated entries) and WebSockets if ((log.type === 'network' || log.type === 'graphql') && filters.domains && filters.domains.length > 0) { let url; if (log.type === 'network') { const details = log.details; if ('url' in details && typeof details.url === 'string') { // NetworkRequest or NetworkResponse url = details.url; } else if ('request' in details && typeof details.request.url === 'string') { // CorrelatedNetworkEntry url = details.request.url; } else { logger.warn('Invalid network entry for domain filtering: missing URL', { log }); return false; } } else { // log.type === 'graphql' const details = log.details; url = details.url; } if (!filters.domains.some(domain => new RegExp(domain.replace(/\*/g, '.*')).test(url))) { return false; } } else if (log.type === 'websocket' && filters.domains && filters.domains.length > 0) { const details = log.details; if (!details || typeof details.url !== 'string') { logger.warn('Invalid websocket entry for domain filtering: missing URL', { log }); return false; } const url = details.url; if (!filters.domains.some(domain => new RegExp(domain.replace(/\*/g, '.*')).test(url))) { return false; } } // Check keywords if (filters.keywords && filters.keywords.length > 0) { try { const text = JSON.stringify(log.details); if (!filters.keywords.some(keyword => text.includes(keyword))) { return false; } } catch (error) { logger.warn('Failed to stringify log details for keyword filtering', { error, log }); return false; } } // Check exclude patterns if (filters.excludePatterns && filters.excludePatterns.length > 0) { try { const text = JSON.stringify(log.details); if (filters.excludePatterns.some(pattern => new RegExp(pattern).test(text))) { return false; } } catch (error) { logger.warn('Failed to stringify log details for exclude pattern filtering', { error, log }); return false; } } return true; } /** * Evaluates an advanced boolean filter expression against a log entry * Supports AND, OR, NOT, and parentheses for grouping * Example: "(React AND error) OR (network AND 404)" */ evaluateAdvancedFilter(expression, log) { try { // Convert log to string for matching const logString = JSON.stringify(log.details); // Parse expression and evaluate return this.parseAdvancedFilterExpression(expression, logString); } catch (error) { logger.warn('Failed to evaluate advanced filter expression', { expression, error, logType: log.type }); return false; } } /** * Parses and evaluates a boolean filter expression * This is a simple recursive descent parser for boolean expressions */ parseAdvancedFilterExpression(expression, logText) { // Trim whitespace expression = expression.trim(); if (!expression) { return true; // Empty expression matches everything } // Handle parenthesized expressions if (expression.startsWith('(')) { // Find matching closing parenthesis let depth = 1; let closePos = 1; while (depth > 0 && closePos < expression.length) { if (expression[closePos] === '(') { depth++; } else if (expression[closePos] === ')') { depth--; } closePos++; } if (depth !== 0) { logger.warn('Mismatched parentheses in filter expression', { expression }); return false; } // Extract and evaluate the inner expression const innerExpr = expression.substring(1, closePos - 1); const innerResult = this.parseAdvancedFilterExpression(innerExpr, logText); // If this is the entire expression, return the result if (closePos === expression.length) { return innerResult; } // Otherwise, continue parsing the rest with the result of the inner expression const rest = expression.substring(closePos).trim(); if (rest.toUpperCase().startsWith('AND')) { return innerResult && this.parseAdvancedFilterExpression(rest.substring(3), logText); } else if (rest.toUpperCase().startsWith('OR')) { return innerResult || this.parseAdvancedFilterExpression(rest.substring(2), logText); } else { logger.warn('Invalid operator in filter expression', { expression, rest }); return false; } } // Handle NOT expressions if (expression.toUpperCase().startsWith('NOT')) { return !this.parseAdvancedFilterExpression(expression.substring(3).trim(), logText); } // Split by AND/OR operators (outside of parentheses) const andPos = this.findOperatorPosition(expression, 'AND'); const orPos = this.findOperatorPosition(expression, 'OR'); // Handle AND expressions (higher precedence than OR) if (andPos !== -1 && (orPos === -1 || andPos < orPos)) { const left = expression.substring(0, andPos).trim(); const right = expression.substring(andPos + 3).trim(); return this.parseAdvancedFilterExpression(left, logText) && this.parseAdvancedFilterExpression(right, logText); } // Handle OR expressions if (orPos !== -1) { const left = expression.substring(0, orPos).trim(); const right = expression.substring(orPos + 2).trim(); return this.parseAdvancedFilterExpression(left, logText) || this.parseAdvancedFilterExpression(right, logText); } // Base case: simple keyword match return logText.includes(expression); } /** * Helper to find the position of a boolean operator outside of parentheses */ findOperatorPosition(expression, operator) { let pos = 0; let parenDepth = 0; while (pos < expression.length) { if (expression[pos] === '(') { parenDepth++; } else if (expression[pos] === ')') { parenDepth--; } else if (parenDepth === 0) { // Check if we have the operator at this position if (expression.substring(pos, pos + operator.length).toUpperCase() === operator) { // Verify it's a whole word by checking boundaries const prevChar = pos > 0 ? expression[pos - 1] : ' '; const nextChar = pos + operator.length < expression.length ? expression[pos + operator.length] : ' '; if (/\s/.test(prevChar) && /\s/.test(nextChar)) { return pos; } } } pos++; } return -1; // Operator not found } formatLogs(logs) { const formattedLogs = logs.filter(log => this.shouldIncludeLog(log)); if (this.options.format.groupBySource || this.options.format.groupByLevel) { const groups = {}; formattedLogs.forEach(log => { let groupKey = ''; if (this.options.format.groupBySource) { groupKey += `[${log.type}]`; } if (this.options.format.groupByLevel && (log.type === 'console' || log.type === 'log')) { const level = log.details.level; groupKey += `[${level}]`; } groups[groupKey] = groups[groupKey] || []; groups[groupKey].push(log); }); return Object.entries(groups).map(([groupKey, groupLogs]) => { const content = groupLogs.map(log => { let entry = ''; if (this.options.format.includeTimestamp) { entry += `[${log.timestamp}] `; } if (log.type === 'network') { const details = log.details; if ('request' in details && 'response' in details) { entry += `[CORRELATED] ${details.request.method} ${details.request.url} - Status: ${details.response.status}`; } else if ('url' in details) { // NetworkRequest entry += `${details.method} ${details.url}`; } else { // NetworkResponse entry += `Response Status: ${details.status}`; } } else if (log.type === 'websocket') { const wsEvent = log.details; if (wsEvent.type === 'webSocketCreated') { entry += `WebSocket Created: ${wsEvent.url}`; } else if (wsEvent.type === 'webSocketClosed') { entry += `WebSocket Closed: ${wsEvent.url}`; } else if (wsEvent.type === 'webSocketFrameSent') { entry += `WebSocket Frame Sent: ${wsEvent.frame?.payloadData}`; } else if (wsEvent.type === 'webSocketFrameReceived') { entry += `WebSocket Frame Received: ${wsEvent.frame?.payloadData}`; } } else if (log.type === 'jwt') { const jwtDetails = log.details; entry += `JWT Token: ${jwtDetails.token} (Expires: ${jwtDetails.expiresAt || 'N/A'})`; } else if (log.type === 'graphql') { const graphqlEvent = log.details; entry += `GraphQL ${graphqlEvent.type === 'request' ? 'Request' : 'Response'}: ${graphqlEvent.url}`; if (graphqlEvent.graphqlQuery) { entry += `\nQuery: ${graphqlEvent.graphqlQuery}`; } if (graphqlEvent.graphqlOperationName) { entry += ` (Operation: ${graphqlEvent.graphqlOperationName})`; } if (graphqlEvent.errors && graphqlEvent.errors.length > 0) { entry += `\nErrors: ${JSON.stringify(graphqlEvent.errors)}`; } } else if (log.type === 'redux') { const reduxEvent = log.details; if (reduxEvent.type === 'action') { entry += `Redux Action: ${reduxEvent.action?.type} (State: ${JSON.stringify(reduxEvent.newState)})`; } else if (reduxEvent.type === 'stateChange') { entry += `Redux State Change: ${JSON.stringify(reduxEvent.newState)}`; } } else if (log.type === 'performance') { const perfMetric = log.details; entry += `Performance Metric: ${perfMetric.name} = ${perfMetric.value} (Title: ${perfMetric.title || 'N/A'})`; } else if (log.type === 'eventloop') { const eventLoopMetric = log.details; entry += `Event Loop (Long Frame): Duration=${eventLoopMetric.duration}ms, Blocking=${eventLoopMetric.blockingDuration}ms`; if (eventLoopMetric.scripts && eventLoopMetric.scripts.length > 0) { entry += `\nScripts: ${eventLoopMetric.scripts.map(s => s.sourceFunctionName || s.sourceURL || 'Unknown').join(', ')}`; } } else if (log.type === 'userinteraction') { const interactionEvent = log.details; entry += `User Interaction: ${interactionEvent.type} on ${interactionEvent.target}`; if (Object.keys(interactionEvent.details).length > 0) { entry += ` (Details: ${JSON.stringify(interactionEvent.details)})`; } } else if (log.type === 'dommutation') { const mutationEvent = log.details; entry += `DOM Mutation: ${mutationEvent.type} on ${mutationEvent.target}`; if (Object.keys(mutationEvent.details).length > 0) { entry += ` (Details: ${JSON.stringify(mutationEvent.details)})`; } } else if (log.type === 'csschange') { const cssChangeEvent = log.details; entry += `CSS Change: ${cssChangeEvent.type} on stylesheet ${cssChangeEvent.styleSheetId}`; } else if (log.type === 'credential') { const credentialDetails = log.details; entry += `Credential Detected: Type=${credentialDetails.type}, Source=${credentialDetails.source}, Key=${credentialDetails.key || 'N/A'}`; } else if (log.type === 'reacthook') { const reactHookEvent = log.details; entry += reactHookEvent.text; if (reactHookEvent.parameters && reactHookEvent.parameters.length > 0) { entry += ` (Parameters: ${JSON.stringify(reactHookEvent.parameters)})`; } } else { const msg = log.details; entry += msg.text; if (this.options.format.includeStackTrace && 'stack' in msg && msg.stack) { entry += `\n${msg.stack}`; } } return entry; }).join('\n'); const sanitizedContent = this.dataSanitizer.sanitize(content); return { filePath: `chrome-devtools://${groupKey}`, content: sanitizedContent, size: sanitizedContent.length, lastModified: new Date(groupLogs[0].timestamp) }; }); } return formattedLogs.map(log => { let content = ''; if (log.type === 'network') { const details = log.details; if ('request' in details && 'response' in details) { content = JSON.stringify({ request: details.request, response: details.response }, null, 2); } else { content = JSON.stringify(details, null, 2); } } else if (log.type === 'websocket') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'jwt') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'graphql') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'redux') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'performance') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'eventloop') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'userinteraction') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'dommutation') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'csschange') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'credential') { content = JSON.stringify(log.details, null, 2); } else if (log.type === 'reacthook') { const reactHookEvent = log.details; content = reactHookEvent.text; if (reactHookEvent.parameters && reactHookEvent.parameters.length > 0) { content += ` (Parameters: ${JSON.stringify(reactHookEvent.parameters)})`; } } else { content = JSON.stringify(log.details, null, 2); } const sanitizedContent = this.dataSanitizer.sanitize(content); return { filePath: `chrome-devtools://${log.type}/${log.timestamp}`, content: sanitizedContent, size: sanitizedContent.length, lastModified: new Date(log.timestamp) }; }); } /** * Helper to decode and parse JWT tokens. * This is a basic decoder and does not verify the signature. */ parseJwt(token) { try { const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const decodedPayload = JSON.parse(Buffer.from(base64, 'base64').toString()); const header = JSON.parse(Buffer.from(token.split('.')[0], 'base64').toString()); let expiresAt; if (decodedPayload.exp) { expiresAt = new Date(decodedPayload.exp * 1000).toISOString(); } let issuedAt; if (decodedPayload.iat) { issuedAt = new Date(decodedPayload.iat * 1000).toISOString(); } return { token, decodedPayload, header, signatureVerified: false, // Cannot verify signature without key expiresAt, issuedAt }; } catch (error) { logger.warn('Failed to parse JWT token', { token, error }); return { token, signatureVerified: false }; } } async extract(targetId) { let client; let networkEnabled = false; let consoleEnabled = false; let runtimeEnabled = false; let websocketEnabled = false; let jwtMonitoringEnabled = false; let graphqlMonitoringEnabled = false; let reduxMonitoringEnabled = false; let performanceMonitoringEnabled = false; let eventLoopMonitoringEnabled = false; let userInteractionRecordingEnabled = false; let domMutationMonitoringEnabled = false; let cssChangeMonitoringEnabled = false; // Reset logs array at the start of extraction this.collectedLogs = []; try { logger.info('Connecting to Chrome DevTools...', { options: this.options }); let debuggerUrl; if (this.options.headless) { // In headless mode, we might need to launch Chrome ourselves. // For simplicity, we'll assume a running instance for now, but this is where // you would add logic to launch a headless chrome instance. logger.info('Headless mode enabled, but auto-launch not implemented. Assuming running instance.'); } if (targetId) { const targets = await this.getDebuggablePageTargets(); const selectedTarget = targets.find(t => t.id === targetId); if (!selectedTarget) { throw new Error(`Chrome target with ID '${targetId}' not found.`); } debuggerUrl = selectedTarget.webSocketDebuggerUrl; } else { // Fallback for direct CDP connection if no targetId is provided (e.g. for internal testing/direct usage) const targets = await this.getDebuggablePageTargets(); if (targets.length === 0) { throw new Error('No debuggable Chrome tabs found.'); } debuggerUrl = targets[0].webSocketDebuggerUrl; logger.warn('No specific target ID provided. Automatically selecting the first available page target.', { target: targets[0].url }); } logger.info('Got debugger URL', { debuggerUrl }); client = await this.connectToDebugger(debuggerUrl); logger.info('Connected to Chrome'); const { Network, Console, Log, Runtime, CSS, Performance } = client; // Helper function to convert Chrome timestamp to ISO string const toISOString = (timestamp) => { try { // Handle null, undefined, or invalid timestamp values if (timestamp === null || timestamp === undefined || timestamp === 0) { // Use current time as fallback without warning for expected cases return new Date().toISOString(); } // Handle string timestamps that might come from some Chrome events if (typeof timestamp === 'string') { const parsed = parseFloat(timestamp); if (!isNaN(parsed)) { timestamp = parsed; } else { logger.debug('Invalid string timestamp, using current time', { timestamp }); return new Date().toISOString(); } } // If timestamp is in seconds (less than year 2100), convert to milliseconds const ms = timestamp < 4102444800 ? timestamp * 1000 : timestamp; // Validate timestamp is within reasonable range if (isNaN(ms) || ms < 0 || ms > 9999999999999) { logger.debug('Invalid timestamp value out of range, using current time', { timestamp, ms }); return new Date().toISOString(); // Use current time as fallback } return new Date(ms).toISOString(); } catch (error) { logger.debug('Invalid timestamp encountered, using current time', { timestamp, error }); return new Date().toISOString(); // Use current time as fallback } }; // Set up disconnect handling for page refreshes let isReconnecting = false; let reconnectAttempts = 0; client.on('disconnect', async () => { logger.warn('Chrome DevTools connection lost, possibly due to page refresh'); if (!this.options.reconnect?.enabled || isReconnecting) { return; } isReconnecting = true; while (reconnectAttempts < (this.options.reconnect?.maxAttempts || 5)) { try { reconnectAttempts++; logger.info(`Attempting to reconnect (${reconnectAttempts}/${this.options.reconnect?.maxAttempts || 5})...`); await new Promise(resolve => setTimeout(resolve, this.options.reconnect?.delayBetweenAttemptsMs || 2000)); // Get a fresh debugger URL for the *same* target if possible let newDebuggerUrl; if (targetId) { const newTargets = await this.getDebuggablePageTargets(); const reselectedTarget = newTargets.find(t => t.id === targetId); if (reselectedTarget) { newDebuggerUrl = reselectedTarget.webSocketDebuggerUrl; } } else { // If no targetId was initially provided, try to find the first page again const newTargets = await this.getDebuggablePageTargets(); if (newTargets.length > 0) { newDebuggerUrl = newTargets[0].webSocketDebuggerUrl; } } if (!newDebuggerUrl) { logger.error('Could not find a valid debugger URL for reconnection.'); throw new Error('Could not find a valid debugger URL for reconnection.'); } await client.close(); client = await this.connectToDebugger(newDebuggerUrl); logger.info('Reconnected to Chrome DevTools'); // Re-enable required domains if (this.options.includeNetwork) { await client.Network.enable(); } if (this.options.includeConsole) { await client.Console.enable(); await client.Log.enable(); } if (this.options.includeWebSockets) { await client.Network.enable(); // Network domain is needed for WebSockets } if (this.options.includeJwtMonitoring) { await client.Network.enable(); // Network domain is needed for JWT monitoring } if (this.options.includeGraphqlMonitoring) { await client.Network.enable(); // Network domain is needed for GraphQL monitoring } if (this.options.includeReduxMonitoring) { await client.Runtime.enable(); // Runtime domain is needed for Redux monitoring } if (this.options.includePerformanceMonitoring) { await client.Performance.enable(); // Performance domain is needed for performance monitoring } if (this.options.includeEventLoopMonitoring) { await client.Runtime.enable(); // Runtime domain is needed for Event Loop monitoring } if (this.options.includeUserInteractionRecording) { await client.Runtime.enable(); } if (this.options.includeDomMutationMonitoring) { await client.Runtime.enable(); } if (this.options.includeCssChangeMonitoring) { await client.CSS.enable(); } isReconnecting = false; reconnectAttempts = 0; break; } catch (error) { logger.error(`Reconnection attempt ${reconnectAttempts} failed`, { error }); if (reconnectAttempts >= (this.options.reconnect?.maxAttempts || 5)) { logger.error('Maximum reconnection attempts reached'); break; } } } }); if (this.options.enableReactHookMonitoring) { try { await Runtime.enable(); await Runtime.evaluate({ expression: REACT_HOOK_MONITOR_SCRIPT, silent: true }); runtimeEnabled = true; logger.info('React hooks monitoring script injected.'); } catch (error) { logger.error('Failed to inject React hooks monitoring script', { error }); this.options.enableReactHookMonitoring = false; } } if (this.options.includeNetwork) { try { await Network.enable(); networkEnabled = true; logger.info('Network tracking enabled'); } catch (error) { logger.error('Failed to enable Network domain', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeConsole) { try { await Console.enable(); await Log.enable(); consoleEnabled = true; logger.info('Console tracking enabled'); } catch (error) { logger.error('Failed to enable Console/Log domain', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeWebSockets) { try { await Network.enable(); websocketEnabled = true; logger.info('WebSocket tracking enabled'); } catch (error) { logger.error('Failed to enable Network domain for WebSocket tracking', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeJwtMonitoring) { try { await Network.enable(); jwtMonitoringEnabled = true; logger.info('JWT token monitoring enabled'); } catch (error) { logger.error('Failed to enable Network domain for JWT monitoring', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeGraphqlMonitoring) { try { await Network.enable(); graphqlMonitoringEnabled = true; logger.info('GraphQL monitoring enabled'); } catch (error) { logger.error('Failed to enable Network domain for GraphQL monitoring', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeReduxMonitoring) { try { await Runtime.enable(); await Runtime.evaluate({ expression: REDUX_STATE_MONITOR_SCRIPT, silent: true }); reduxMonitoringEnabled = true; logger.info('Redux state monitoring script injected.'); } catch (error) { logger.error('Failed to inject Redux state monitoring script', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includePerformanceMonitoring) { try { await Performance.enable(); performanceMonitoringEnabled = true; logger.info('Performance monitoring enabled'); } catch (error) { logger.error('Failed to enable Performance domain', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeEventLoopMonitoring) { try { await Runtime.enable(); await Runtime.evaluate({ expression: EVENT_LOOP_MONITOR_SCRIPT, silent: true }); eventLoopMonitoringEnabled = true; logger.info('Event loop monitoring script injected.'); } catch (error) { logger.error('Failed to inject Event loop monitoring script', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeUserInteractionRecording) { try { await Runtime.enable(); await Runtime.evaluate({ expression: USER_INTERACTION_MONITOR_SCRIPT, silent: true }); userInteractionRecordingEnabled = true; logger.info('User interaction recording script injected.'); } catch (error) { logger.error('Failed to inject User interaction recording script', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeDomMutationMonitoring) { try { await Runtime.enable(); await Runtime.evaluate({ expression: DOM_MUTATION_MONITOR_SCRIPT, silent: true }); domMutationMonitoringEnabled = true; logger.info('DOM mutation monitoring script injected.'); } catch (error) { logger.error('Failed to inject DOM mutation monitoring script', { error }); throw error; // Re-throw error to ensure promise rejection } } if (this.options.includeCssChangeMonitoring) { try { await CSS.enable(); cssChangeMonitoringEnabled = true; logger.info('CSS change monitoring enabled.'); } catch (error) { logger.error('Failed to enable CSS change monitoring', { error }); throw error; // Re-throw error to ensure promise rejection } } // Set up event listeners if (this.options.includeNetwork || this.options.includeGraphqlMonitoring) { Network.requestWillBeSent((params) => { logger.debug('Network request detected', { url: params.request.url, details: params.request }); // Store the request for later correlation this.pendingNetworkRequests.set(params.requestId, params.request); // Check for JWT in request headers if (this.options.includeJwtMonitoring && params.request.headers) { for (const headerName in params.request.headers) { if (headerName.toLowerCase() === 'authorization') { const authHeader = params.request.headers[headerName]; if (authHeader.startsWith('Bearer ')) { const token = authHeader.substring(7); const jwtDetails = this.parseJwt(token); this.collectedLogs.push({ type: 'jwt', timestamp: toISOString(params.timestamp), details: jwtDetails }); logger.info('JWT token found in request', { token: jwtDetails.token }); } else if (authHeader.startsWith('Basic ')) { this.collectedLogs.push({ type: 'credential', timestamp: toISOString(params.timestamp), details: { type: 'basicAuth', value: authHeader, source: 'header', key: headerName } }); logger.info('Basic Auth credentials found in request header'); } } else if (headerName.toLowerCase().includes('key') || headerName.toLowerCase().includes('token')) { this.collectedLogs.push({ type: 'credential', timestamp: toISOString(params.timestamp), details: { type: 'apiKey', value: params.request.headers[headerName], source: 'header', key: headerName } }); logger.info('Potential API key/token found in request header', { key: headerName }); } } } // Check for GraphQL in request if (this.options.includeGraphqlMonitoring && params.method === 'POST' && params.request.postData) { try { const postData = JSON.parse(params.request.postData); if (postData.query) { // This is likely a GraphQL request const graphqlEvent = { requestId: params.requestId, url: params.request.url, requestBody: postData, graphqlQuery: postData.query, graphqlVariables: postData.variables, graphqlOperationName: postData.operationName, timestamp: params.timestamp, type: 'request' }; this.collectedLogs.push({ type: 'graphql', timestamp: toISOString(params.timestamp), details: graphqlEvent }); this.pendingGraphqlRequests.set(params.requestId, params.request); // Store original request for response correlation logger.info('GraphQL request detected', { url: params.request.url, query: postData.query }); } } catch (e) { // Not a JSON postData, or not a GraphQL query } } }); Network.responseReceived((params) => { logger.debug('Network response detected', { status: params.response.status, details: params.response }); const request = this.pendingNetworkRequests.get(params.requestId); if (request) { // Correlate request and response const correlatedEntry = { request: request, response: params.response, }; this.collectedLogs.push({ type: 'network', timestamp: toISOString(params.timestamp), details: correlatedEntry }); // Remove from pending requests this.pend