@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
JavaScript
// 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