*/ private static $seen_errors = array(); /** * Previous error handler callback. * * @var mixed */ private static $prev_error_handler = null; /** * Handler initialization status flag. * * @var bool */ private static $initialized = false; /** * Pending logs queue for early bootstrap errors before $wpdb is initialized. * * @var array */ private static $pending_logs = array(); /** * Previous exception handler callback. * * @var mixed */ private static $prev_exception_handler = null; /** * Sensitive key patterns to redact. * * @var array */ private static $sensitive_patterns = array( '/([?&](?:pwd|pass|password|token|secret|api_key|access_token|authorization|auth)=)[^&]+/i', '/(Bearer\s+)[A-Za-z0-9\-\._~\+\/]+=*/i', '/(Basic\s+)[A-Za-z0-9\+\/]+=*/i', '/(["\']?(?:pwd|pass|password|token|secret|apiKey|api_key|access_token|auth_token|client_secret)["\']?\s*[:=]\s*["\'])([^"\']+)(["\'])/i', '/\b(?:\d[ -]*?){13,16}\b/', // Credit card pattern ); /** * Register handlers. * * @return void */ public static function init(): void { if ( self::$initialized ) { return; } self::$initialized = true; $options = function_exists( 'get_option' ) ? get_option( 'errorwatcher_settings', array() ) : array(); // If monitoring is explicitly disabled in settings, do not register handlers. if ( isset( $options['enabled'] ) && ! $options['enabled'] ) { return; } // Reserve 10KB memory for fatal out-of-memory situations. self::$memory_reserve = str_repeat( 'x', 10240 ); // Set PHP Error Handler for all severities. // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_set_error_handler -- Essential core handler for error monitoring. self::$prev_error_handler = set_error_handler( array( self::class, 'handle_error' ), E_ALL ); // Set Uncaught Exception Handler. self::$prev_exception_handler = set_exception_handler( array( self::class, 'handle_exception' ) ); // Set Shutdown Function for Fatal Errors. register_shutdown_function( array( self::class, 'handle_shutdown' ) ); // Hook into WordPress core actions to capture notices & deprecations even when WP_DEBUG is false. if ( function_exists( 'add_action' ) ) { add_action( 'doing_it_wrong_run', array( self::class, 'handle_doing_it_wrong' ), 10, 3 ); add_action( 'deprecated_function_run', array( self::class, 'handle_deprecated_function' ), 10, 3 ); add_action( 'deprecated_file_run', array( self::class, 'handle_deprecated_file' ), 10, 4 ); add_action( 'deprecated_argument_run', array( self::class, 'handle_deprecated_argument' ), 10, 3 ); add_action( 'deprecated_hook_run', array( self::class, 'handle_deprecated_hook' ), 10, 4 ); add_action( 'plugins_loaded', array( self::class, 'flush_pending_logs' ) ); add_action( 'init', array( self::class, 'flush_pending_logs' ) ); add_action( 'shutdown', array( self::class, 'flush_pending_logs' ) ); } } /** * Custom error handler for notices, warnings, deprecations, user errors. * * @param int $errno Error level. * @param string $errstr Error message. * @param string $errfile Error file path. * @param int $errline Error line number. * @return bool */ public static function handle_error( int $errno, string $errstr, string $errfile, int $errline ): bool { // Prevent recursive error logging loops. if ( self::$is_handling ) { return false; } // Check if error level is enabled in settings. if ( ! self::is_level_enabled( $errno ) ) { return false; } // Deduplicate repeated errors within the same HTTP request (e.g. loops). $hash = md5( $errno . '|' . $errstr . '|' . $errfile . '|' . $errline ); if ( isset( self::$seen_errors[ $hash ] ) ) { self::$seen_errors[ $hash ]++; $display_on_screen = defined( 'WP_DEBUG' ) && WP_DEBUG && ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY ); return ! $display_on_screen; } self::$seen_errors[ $hash ] = 1; self::$is_handling = true; try { $type_name = self::get_severity_name( $errno ); $trace = self::generate_clean_backtrace( 2 ); // Skip internal handler frames self::log_to_db( array( 'error_type' => $type_name, 'severity_code' => $errno, 'message' => self::scrub_sensitive_data( $errstr ), 'file' => self::scrub_sensitive_data( $errfile ), 'line' => $errline, 'stack_trace' => self::scrub_sensitive_data( $trace ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail silently to prevent crashing the site. } finally { self::$is_handling = false; } // Respect WP_DEBUG and WP_DEBUG_DISPLAY: // If WP_DEBUG and WP_DEBUG_DISPLAY are true, return false so PHP displays the error on screen normally. // If WP_DEBUG is false, return true to suppress screen output on production. $display_on_screen = defined( 'WP_DEBUG' ) && WP_DEBUG && ( ! defined( 'WP_DEBUG_DISPLAY' ) || WP_DEBUG_DISPLAY ); return ! $display_on_screen; } /** * Uncaught Exception handler. * * @param \Throwable $exception Uncaught exception or error. * @return void */ public static function handle_exception( \Throwable $exception ): void { if ( self::$is_handling ) { return; } self::$is_handling = true; try { $errstr = get_class( $exception ) . ': ' . $exception->getMessage(); $errfile = $exception->getFile(); $errline = $exception->getLine(); $hash = md5( 'exception|' . $errstr . '|' . $errfile . '|' . $errline ); $is_fatal = ( $exception instanceof \Error ); $error_type = $is_fatal ? 'Fatal Error' : 'Uncaught Exception'; self::log_to_db( array( 'error_type' => $error_type, 'severity_code' => $exception->getCode() ?: E_ERROR, 'message' => self::scrub_sensitive_data( $errstr ), 'file' => self::scrub_sensitive_data( $errfile ), 'line' => $errline, 'stack_trace' => self::scrub_sensitive_data( $exception->getTraceAsString() ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail silently. } finally { self::$is_handling = false; } // Chain to previous exception handler if exists. if ( is_callable( self::$prev_exception_handler ) ) { call_user_func( self::$prev_exception_handler, $exception ); } } /** * Shutdown handler for capturing fatal errors. * * @return void */ public static function handle_shutdown(): void { // Free reserved memory buffer to ensure database write can succeed. self::$memory_reserve = null; $last_error = error_get_last(); if ( null === $last_error ) { return; } $fatal_types = array( E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR, ); if ( ! in_array( $last_error['type'], $fatal_types, true ) ) { return; } // Check if already captured. $hash = md5( $last_error['type'] . '|' . $last_error['message'] . '|' . $last_error['file'] . '|' . $last_error['line'] ); if ( isset( self::$seen_errors[ $hash ] ) ) { return; } if ( self::$is_handling ) { return; } self::$is_handling = true; try { $type_name = self::get_severity_name( $last_error['type'] ); self::log_to_db( array( 'error_type' => $type_name, 'severity_code' => $last_error['type'], 'message' => self::scrub_sensitive_data( $last_error['message'] ), 'file' => self::scrub_sensitive_data( $last_error['file'] ), 'line' => (int) $last_error['line'], 'stack_trace' => 'Fatal error caught at script shutdown.', 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail safely. } finally { self::$is_handling = false; } } /** * Handle WordPress _doing_it_wrong() notices even when WP_DEBUG is false. * * @param string $function_name * @param string $message * @param string $version * @return void */ public static function handle_doing_it_wrong( string $function_name, string $message, string $version ): void { if ( ! self::is_level_enabled( E_USER_NOTICE ) || self::$is_handling ) { return; } $formatted_message = sprintf( 'Function %1$s was called incorrectly. %2$s (This message was added in version %3$s.)', $function_name, $message, $version ); $caller = self::find_caller_file_and_line(); $hash = md5( E_USER_NOTICE . '|' . $formatted_message . '|' . $caller['file'] . '|' . $caller['line'] ); if ( isset( self::$seen_errors[ $hash ] ) ) { self::$seen_errors[ $hash ]++; return; } self::$seen_errors[ $hash ] = 1; self::$is_handling = true; try { self::log_to_db( array( 'error_type' => 'User Notice', 'severity_code' => E_USER_NOTICE, 'message' => self::scrub_sensitive_data( $formatted_message ), 'file' => self::scrub_sensitive_data( $caller['file'] ), 'line' => $caller['line'], 'stack_trace' => self::scrub_sensitive_data( self::generate_clean_backtrace() ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail safely. } finally { self::$is_handling = false; } } /** * Handle WordPress deprecated function calls even when WP_DEBUG is false. * * @param string $function * @param string $replacement * @param string $version * @return void */ public static function handle_deprecated_function( string $function, string $replacement, string $version ): void { if ( ! self::is_level_enabled( E_USER_DEPRECATED ) || self::$is_handling ) { return; } $formatted_message = empty( $replacement ) ? sprintf( 'Function %1$s is deprecated since version %2$s with no alternative available.', $function, $version ) : sprintf( 'Function %1$s is deprecated since version %2$s! Use %3$s instead.', $function, $version, $replacement ); $caller = self::find_caller_file_and_line(); $hash = md5( E_USER_DEPRECATED . '|' . $formatted_message . '|' . $caller['file'] . '|' . $caller['line'] ); if ( isset( self::$seen_errors[ $hash ] ) ) { self::$seen_errors[ $hash ]++; return; } self::$seen_errors[ $hash ] = 1; self::$is_handling = true; try { self::log_to_db( array( 'error_type' => 'Deprecated', 'severity_code' => E_USER_DEPRECATED, 'message' => self::scrub_sensitive_data( $formatted_message ), 'file' => self::scrub_sensitive_data( $caller['file'] ), 'line' => $caller['line'], 'stack_trace' => self::scrub_sensitive_data( self::generate_clean_backtrace() ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail safely. } finally { self::$is_handling = false; } } /** * Handle WordPress deprecated file calls. */ public static function handle_deprecated_file( string $file, string $replacement, string $version, string $message = '' ): void { if ( ! self::is_level_enabled( E_USER_DEPRECATED ) || self::$is_handling ) { return; } $formatted_message = empty( $replacement ) ? sprintf( 'File %1$s is deprecated since version %2$s with no alternative available.', $file, $version ) : sprintf( 'File %1$s is deprecated since version %2$s! Use %3$s instead.', $file, $version, $replacement ); if ( ! empty( $message ) ) { $formatted_message .= ' ' . $message; } $caller = self::find_caller_file_and_line(); $hash = md5( E_USER_DEPRECATED . '|' . $formatted_message . '|' . $caller['file'] . '|' . $caller['line'] ); if ( isset( self::$seen_errors[ $hash ] ) ) { self::$seen_errors[ $hash ]++; return; } self::$seen_errors[ $hash ] = 1; self::$is_handling = true; try { self::log_to_db( array( 'error_type' => 'Deprecated', 'severity_code' => E_USER_DEPRECATED, 'message' => self::scrub_sensitive_data( $formatted_message ), 'file' => self::scrub_sensitive_data( $caller['file'] ), 'line' => $caller['line'], 'stack_trace' => self::scrub_sensitive_data( self::generate_clean_backtrace() ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail safely. } finally { self::$is_handling = false; } } /** * Handle WordPress deprecated argument calls. */ public static function handle_deprecated_argument( string $function, string $message, string $version ): void { if ( ! self::is_level_enabled( E_USER_DEPRECATED ) || self::$is_handling ) { return; } $formatted_message = sprintf( 'Function %1$s was called with an argument that is deprecated since version %2$s. %3$s', $function, $version, $message ); $caller = self::find_caller_file_and_line(); $hash = md5( E_USER_DEPRECATED . '|' . $formatted_message . '|' . $caller['file'] . '|' . $caller['line'] ); if ( isset( self::$seen_errors[ $hash ] ) ) { self::$seen_errors[ $hash ]++; return; } self::$seen_errors[ $hash ] = 1; self::$is_handling = true; try { self::log_to_db( array( 'error_type' => 'Deprecated', 'severity_code' => E_USER_DEPRECATED, 'message' => self::scrub_sensitive_data( $formatted_message ), 'file' => self::scrub_sensitive_data( $caller['file'] ), 'line' => $caller['line'], 'stack_trace' => self::scrub_sensitive_data( self::generate_clean_backtrace() ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail safely. } finally { self::$is_handling = false; } } /** * Handle WordPress deprecated hook calls. */ public static function handle_deprecated_hook( string $hook, string $replacement, string $version, string $message = '' ): void { if ( ! self::is_level_enabled( E_USER_DEPRECATED ) || self::$is_handling ) { return; } $formatted_message = empty( $replacement ) ? sprintf( 'Hook %1$s is deprecated since version %2$s with no alternative available.', $hook, $version ) : sprintf( 'Hook %1$s is deprecated since version %2$s! Use %3$s instead.', $hook, $version, $replacement ); if ( ! empty( $message ) ) { $formatted_message .= ' ' . $message; } $caller = self::find_caller_file_and_line(); $hash = md5( E_USER_DEPRECATED . '|' . $formatted_message . '|' . $caller['file'] . '|' . $caller['line'] ); if ( isset( self::$seen_errors[ $hash ] ) ) { self::$seen_errors[ $hash ]++; return; } self::$seen_errors[ $hash ] = 1; self::$is_handling = true; try { self::log_to_db( array( 'error_type' => 'Deprecated', 'severity_code' => E_USER_DEPRECATED, 'message' => self::scrub_sensitive_data( $formatted_message ), 'file' => self::scrub_sensitive_data( $caller['file'] ), 'line' => $caller['line'], 'stack_trace' => self::scrub_sensitive_data( self::generate_clean_backtrace() ), 'request_url' => self::get_current_url(), 'http_method' => isset( $_SERVER['REQUEST_METHOD'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) : 'CLI', 'user_id' => function_exists( 'get_current_user_id' ) ? (int) get_current_user_id() : 0, 'ip_address' => self::get_client_ip(), 'hash' => $hash, ) ); } catch ( \Throwable $e ) { // Fail safely. } finally { self::$is_handling = false; } } /** * Find the file and line number outside of WP core that triggered the doing_it_wrong or deprecation. * * @return array{ file: string, line: int } */ private static function find_caller_file_and_line(): array { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Backtrace required to detect error origin. $backtrace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS, 15 ); $found = array( 'file' => 'unknown', 'line' => 0 ); foreach ( $backtrace as $frame ) { $file = $frame['file'] ?? ''; if ( empty( $file ) ) { continue; } $norm = str_replace( '\\', '/', $file ); if ( false !== strpos( $norm, 'class-errorwatcher' ) || false !== strpos( $norm, 'wp-includes/functions.php' ) || false !== strpos( $norm, 'wp-includes/load.php' ) ) { continue; } $found = array( 'file' => $file, 'line' => (int) ( $frame['line'] ?? 0 ), ); break; } return $found; } /** * Insert log entry into runtime store. * * @param array $data * @return void */ private static function log_to_db( array $data ): void { if ( ! class_exists( 'ErrorWatcher_DB' ) ) { $plugins_dir = defined( 'WP_PLUGIN_DIR' ) ? WP_PLUGIN_DIR : ( defined( 'WP_CONTENT_DIR' ) ? WP_CONTENT_DIR . '/plugins' : '' ); $db_file = defined( 'ERRORWATCHER_DIR' ) ? ERRORWATCHER_DIR . 'includes/class-errorwatcher-db.php' : trailingslashit( $plugins_dir ) . 'errorwatcher/includes/class-errorwatcher-db.php'; if ( ! empty( $db_file ) && file_exists( $db_file ) ) { require_once $db_file; } } if ( class_exists( 'ErrorWatcher_DB' ) ) { ErrorWatcher_DB::get_instance()->insert_log( $data ); } } /** * Flush any early pending logs (kept for backwards compatibility). * * @return void */ public static function flush_pending_logs(): void { // Handled directly via runtime store. } /** * Check if a given error code is enabled in user settings. * * @param int $errno * @return bool */ private static function is_level_enabled( int $errno ): bool { $options = get_option( 'errorwatcher_settings', array() ); // Defaults: all enabled. $capture_fatals = $options['capture_fatals'] ?? true; $capture_errors = $options['capture_errors'] ?? true; $capture_warnings = $options['capture_warnings'] ?? true; $capture_notices = $options['capture_notices'] ?? true; $capture_deprecated = $options['capture_deprecated'] ?? true; switch ( $errno ) { case E_ERROR: case E_PARSE: case E_CORE_ERROR: case E_COMPILE_ERROR: case E_USER_ERROR: return (bool) $capture_fatals; case E_RECOVERABLE_ERROR: return (bool) $capture_errors; case E_WARNING: case E_CORE_WARNING: case E_COMPILE_WARNING: case E_USER_WARNING: return (bool) $capture_warnings; case E_NOTICE: case E_USER_NOTICE: return (bool) $capture_notices; case E_DEPRECATED: case E_USER_DEPRECATED: return (bool) $capture_deprecated; default: return true; } } /** * Convert PHP error constant to readable string. * * @param int $errno * @return string */ public static function get_severity_name( int $errno ): string { switch ( $errno ) { case E_ERROR: case E_CORE_ERROR: case E_COMPILE_ERROR: return 'Fatal Error'; case E_PARSE: return 'Parse Error'; case E_WARNING: case E_CORE_WARNING: case E_COMPILE_WARNING: return 'Warning'; case E_USER_WARNING: return 'User Warning'; case E_NOTICE: return 'Notice'; case E_USER_NOTICE: return 'User Notice'; case E_USER_ERROR: return 'User Error'; case E_RECOVERABLE_ERROR: return 'Recoverable Error'; case E_DEPRECATED: return 'Deprecated'; case E_USER_DEPRECATED: return 'User Deprecated'; default: return 'PHP Error (' . $errno . ')'; } } /** * Format backtrace into readable text string. * * @param int $skip_frames Number of initial stack frames to skip. * @return string */ private static function generate_clean_backtrace( int $skip_frames = 1 ): string { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- Backtrace required for error diagnostics. $trace = debug_backtrace( DEBUG_BACKTRACE_IGNORE_ARGS ); if ( empty( $trace ) ) { return 'No backtrace available.'; } // Remove internal error handler frames. for ( $i = 0; $i < $skip_frames; $i++ ) { if ( ! empty( $trace ) ) { array_shift( $trace ); } } $output = array(); foreach ( $trace as $index => $frame ) { $file = $frame['file'] ?? '[internal function]'; $line = isset( $frame['line'] ) ? ":{$frame['line']}" : ''; $function = $frame['function'] ?? 'unknown'; $class = isset( $frame['class'] ) ? $frame['class'] . $frame['type'] : ''; $output[] = sprintf( '#%d %s%s: %s%s()', $index, $file, $line, $class, $function ); } return implode( "\n", $output ); } /** * Redact sensitive data from string (passwords, tokens, API keys). * * @param string $text * @return string */ public static function scrub_sensitive_data( string $text ): string { if ( empty( $text ) ) { return ''; } $scrubbed = $text; foreach ( self::$sensitive_patterns as $pattern ) { $scrubbed = preg_replace( $pattern, '$1[REDACTED]', $scrubbed ); } return (string) $scrubbed; } /** * Get current request URL safely. * * @return string */ private static function get_current_url(): string { if ( defined( 'WP_CLI' ) && WP_CLI ) { return 'CLI Command'; } $scheme = ( isset( $_SERVER['HTTPS'] ) && 'on' === $_SERVER['HTTPS'] ) ? 'https://' : 'http://'; $host = isset( $_SERVER['HTTP_HOST'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_HOST'] ) ) : ''; $uri = isset( $_SERVER['REQUEST_URI'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; if ( empty( $host ) ) { return ''; } return esc_url_raw( self::scrub_sensitive_data( $scheme . $host . $uri ) ); } /** * Get client IP address safely. * * @return string */ private static function get_client_ip(): string { if ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) { return sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ); } return '127.0.0.1'; } }