|null */ private $runtime_cache = null; /** * In-memory cache of statistics. * * @var array|null */ private $stats_cache = null; /** * File path to the runtime storage JSON file. * * @var string */ private $storage_file = ''; /** * Get singleton instance. * * @return ErrorWatcher_DB */ public static function get_instance(): ErrorWatcher_DB { if ( null === self::$instance ) { self::$instance = new self(); } return self::$instance; } /** * Constructor. */ private function __construct() { $upload_dir = wp_upload_dir(); $base_dir = trailingslashit( $upload_dir['basedir'] ) . 'errorwatcher'; $this->storage_file = $base_dir . '/runtime-errors.json'; $this->ensure_storage_dir( $base_dir ); } /** * Ensure storage directory and protection files exist. * * @param string $dir * @return void */ private function ensure_storage_dir( string $dir ): void { if ( ! is_dir( $dir ) ) { wp_mkdir_p( $dir ); } $index_file = $dir . '/index.php'; if ( ! file_exists( $index_file ) ) { file_put_contents( $index_file, "prefix ) ) { $table_name = esc_sql( $wpdb->prefix . 'errorwatcher_logs' ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.InterpolatedNotPrepared $wpdb->query( "DROP TABLE IF EXISTS {$table_name}" ); } } /** * Load all runtime logs from file. * * @return array */ private function load_runtime_logs(): array { if ( null !== $this->runtime_cache ) { return $this->runtime_cache; } if ( ! file_exists( $this->storage_file ) ) { $this->runtime_cache = array(); return $this->runtime_cache; } $raw = file_get_contents( $this->storage_file ); if ( empty( $raw ) ) { $this->runtime_cache = array(); return $this->runtime_cache; } $data = json_decode( $raw, true ); if ( ! is_array( $data ) ) { $data = array(); } $this->runtime_cache = $data; return $this->runtime_cache; } /** * Save runtime logs to file. * * @param array $logs * @return bool */ private function save_runtime_logs( array $logs ): bool { $this->runtime_cache = $logs; $this->stats_cache = null; $json = wp_json_encode( $logs, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES ); return (bool) file_put_contents( $this->storage_file, $json, LOCK_EX ); } /** * Insert or update a runtime log entry. * * @param array $data Log entry data. * @return int Log ID. */ public function insert_log( array $data ) { $logs = $this->load_runtime_logs(); $defaults = array( 'error_type' => 'Unknown Error', 'severity_code' => 0, 'message' => '', 'file' => '', 'line' => 0, 'stack_trace' => '', 'request_url' => '', 'http_method' => 'GET', 'user_id' => 0, 'ip_address' => '', 'occurrences' => 1, 'created_at' => current_time( 'mysql' ), 'last_seen' => current_time( 'mysql' ), 'timestamp' => time(), 'hash' => '', ); $data = wp_parse_args( $data, $defaults ); $norm_file = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $data['file'] ) : str_replace( '\\', '/', $data['file'] ); $norm_msg = trim( (string) $data['message'] ); $norm_type = trim( (string) $data['error_type'] ); $norm_line = (int) $data['line']; $data['hash'] = md5( strtolower( $norm_file ) . '|' . $norm_line . '|' . strtolower( $norm_type ) . '|' . $norm_msg ); $hash = $data['hash']; if ( isset( $logs[ $hash ] ) ) { $logs[ $hash ]['occurrences'] = (int) ( $logs[ $hash ]['occurrences'] ?? 1 ) + 1; $logs[ $hash ]['last_seen'] = current_time( 'mysql' ); $logs[ $hash ]['timestamp'] = time(); $logs[ $hash ]['request_url'] = esc_url_raw( $data['request_url'] ); $logs[ $hash ]['http_method'] = sanitize_text_field( $data['http_method'] ); $logs[ $hash ]['user_id'] = (int) $data['user_id']; $logs[ $hash ]['ip_address'] = sanitize_text_field( $data['ip_address'] ); $id = $logs[ $hash ]['id']; } else { // Find max ID. $max_id = 0; foreach ( $logs as $item ) { if ( isset( $item['id'] ) && $item['id'] > $max_id ) { $max_id = (int) $item['id']; } } $data['id'] = $max_id + 1; $logs[ $hash ] = $data; $id = $data['id']; } $this->save_runtime_logs( $logs ); return $id; } /** * Check and prune resolved code errors and inactive logs. * * If the source file containing the error was modified AFTER the error occurred, * the resolved error is pruned immediately. Inactive logs older than 30 days are * also pruned to maintain optimal performance and storage. * * @param int $days Optional retention days threshold (default 30). * @return void */ public function prune_resolved_errors( int $days = 30 ): void { $logs = $this->load_runtime_logs(); $changed = false; $now = time(); $max_age = $days * DAY_IN_SECONDS; foreach ( $logs as $hash => $log ) { $file = $log['file'] ?? ''; $ts = $log['timestamp'] ?? 0; // If file exists and was modified after the error was last seen, remove the resolved error. if ( ! empty( $file ) && file_exists( $file ) ) { $mtime = filemtime( $file ); if ( $mtime && $mtime > $ts ) { unset( $logs[ $hash ] ); $changed = true; continue; } } // Prune entries older than retention period (default 30 days). if ( ( $now - $ts ) > $max_age ) { unset( $logs[ $hash ] ); $changed = true; } } if ( $changed ) { $this->save_runtime_logs( $logs ); } } /** * Run log retention cleanup / prune resolved errors (compatibility alias). * * @param int $days Optional retention days. * @return void */ public function cleanup_old_logs( int $days = 30 ): void { $this->prune_resolved_errors( $days ); } /** * Get filtered and paginated active runtime logs. * * @param array $args Query and filter arguments. * @return array */ public function get_logs( array $args = array() ): array { $this->prune_resolved_errors(); $logs = $this->load_runtime_logs(); $defaults = array( 'page' => 1, 'per_page' => 25, 'search' => '', 'severity' => 'all', 'date_range' => '', 'start_date' => '', 'end_date' => '', 'orderby' => 'id', 'order' => 'DESC', ); $args = wp_parse_args( $args, $defaults ); $filtered = array(); // Date Range filter calculation. $filter_start_ts = null; $filter_end_ts = null; $date_range = ! empty( $args['date_range'] ) ? sanitize_text_field( $args['date_range'] ) : ''; $start_date = ! empty( $args['start_date'] ) ? sanitize_text_field( $args['start_date'] ) : ''; $end_date = ! empty( $args['end_date'] ) ? sanitize_text_field( $args['end_date'] ) : ''; $current_time = current_time( 'timestamp' ); $today_str = function_exists( 'wp_date' ) ? wp_date( 'Y-m-d' ) : gmdate( 'Y-m-d', $current_time ); $today_start = strtotime( $today_str . ' 00:00:00' ); $today_end = strtotime( $today_str . ' 23:59:59' ); if ( 'today' === $date_range ) { $filter_start_ts = $today_start; $filter_end_ts = $today_end; } elseif ( 'yesterday' === $date_range ) { $filter_start_ts = $today_start - DAY_IN_SECONDS; $filter_end_ts = $today_start - 1; } elseif ( '7days' === $date_range ) { $filter_start_ts = $today_start - ( 6 * DAY_IN_SECONDS ); $filter_end_ts = $today_end; } elseif ( '30days' === $date_range ) { $filter_start_ts = $today_start - ( 29 * DAY_IN_SECONDS ); $filter_end_ts = $today_end; } elseif ( 'custom' === $date_range || ! empty( $start_date ) || ! empty( $end_date ) ) { if ( ! empty( $start_date ) ) { $parsed_start = false !== strpos( $start_date, ':' ) ? strtotime( $start_date ) : strtotime( $start_date . ' 00:00:00' ); if ( false !== $parsed_start ) { $filter_start_ts = $parsed_start; } } if ( ! empty( $end_date ) ) { $parsed_end = false !== strpos( $end_date, ':' ) ? strtotime( $end_date ) : strtotime( $end_date . ' 23:59:59' ); if ( false !== $parsed_end ) { $filter_end_ts = $parsed_end; } } if ( null !== $filter_start_ts && null !== $filter_end_ts && $filter_start_ts > $filter_end_ts ) { $temp = $filter_start_ts; $filter_start_ts = $filter_end_ts; $filter_end_ts = $temp; } } foreach ( $logs as $item ) { // Search filter. if ( ! empty( $args['search'] ) ) { $search = strtolower( $args['search'] ); $msg = strtolower( $item['message'] ?? '' ); $file = strtolower( $item['file'] ?? '' ); $url = strtolower( $item['request_url'] ?? '' ); if ( false === strpos( $msg, $search ) && false === strpos( $file, $search ) && false === strpos( $url, $search ) ) { continue; } } // Severity filter. if ( ! empty( $args['severity'] ) && 'all' !== $args['severity'] ) { $sev = strtolower( $args['severity'] ); $item_sev = strtolower( $item['error_type'] ?? '' ); if ( 'notice' === $sev && false === strpos( $item_sev, 'notice' ) ) { continue; } elseif ( 'warning' === $sev && false === strpos( $item_sev, 'warning' ) ) { continue; } elseif ( 'fatal error' === $sev && false === strpos( $item_sev, 'fatal' ) && false === strpos( $item_sev, 'error' ) && false === strpos( $item_sev, 'parse' ) ) { continue; } elseif ( 'deprecated' === $sev && false === strpos( $item_sev, 'deprecated' ) ) { continue; } elseif ( 'uncaught exception' === $sev && false === strpos( $item_sev, 'exception' ) ) { continue; } } // Source filter (Plugin, Theme, WordPress Core, Other). if ( ! empty( $args['source'] ) && 'all' !== $args['source'] ) { $source_filter = strtolower( $args['source'] ); $item_source = strtolower( self::detect_source( $item['file'] ?? '' ) ); if ( 'plugin' === $source_filter && 'plugin' !== $item_source ) { continue; } elseif ( 'theme' === $source_filter && 'theme' !== $item_source ) { continue; } elseif ( ( 'core' === $source_filter || 'wordpress core' === $source_filter ) && 'wordpress core' !== $item_source ) { continue; } elseif ( 'other' === $source_filter && 'other' !== $item_source ) { continue; } } // Date range filter. if ( null !== $filter_start_ts || null !== $filter_end_ts ) { $item_time = isset( $item['timestamp'] ) && is_numeric( $item['timestamp'] ) ? (int) $item['timestamp'] : 0; if ( ! $item_time && ! empty( $item['last_seen'] ) ) { $item_time = (int) strtotime( $item['last_seen'] ); } if ( ! $item_time && ! empty( $item['created_at'] ) ) { $item_time = (int) strtotime( $item['created_at'] ); } if ( null !== $filter_start_ts && $item_time < $filter_start_ts ) { continue; } if ( null !== $filter_end_ts && $item_time > $filter_end_ts ) { continue; } } $item['source'] = self::detect_source( $item['file'] ?? '' ); $filtered[] = $item; } // Group matching logs by normalized file path. $file_groups = array(); foreach ( $filtered as $item ) { $file_path = $item['file'] ?? ''; $norm_file = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $file_path ) : str_replace( '\\', '/', $file_path ); $file_key = strtolower( trim( $norm_file ) ); if ( empty( $file_key ) ) { $file_key = 'unknown_' . ( $item['id'] ?? uniqid() ); } if ( ! isset( $file_groups[ $file_key ] ) ) { $file_groups[ $file_key ] = array(); } $file_groups[ $file_key ][] = $item; } // For each file, pick the primary issue (highest severity rank, then latest timestamp). $grouped_items = array(); foreach ( $file_groups as $group_items ) { usort( $group_items, function( $a, $b ) { $rank_a = $this->get_severity_rank( $a['error_type'] ?? '' ); $rank_b = $this->get_severity_rank( $b['error_type'] ?? '' ); if ( $rank_a !== $rank_b ) { return $rank_a <=> $rank_b; } $ts_a = $a['timestamp'] ?? 0; $ts_b = $b['timestamp'] ?? 0; return $ts_b <=> $ts_a; } ); $primary = $group_items[0]; $primary['file_issues_count'] = count( $group_items ); $primary['other_issues'] = array_slice( $group_items, 1 ); $primary['source'] = self::detect_source( $primary['file'] ?? '' ); $grouped_items[] = (object) $primary; } // Sort file-level items by Severity Rank first (Fatal -> Error/Exception -> Warning -> Notice -> Deprecated), then timestamp DESC. usort( $grouped_items, function( $a, $b ) { $rank_a = $this->get_severity_rank( $a->error_type ?? '' ); $rank_b = $this->get_severity_rank( $b->error_type ?? '' ); if ( $rank_a !== $rank_b ) { return $rank_a <=> $rank_b; } $ts_a = $a->timestamp ?? 0; $ts_b = $b->timestamp ?? 0; return $ts_b <=> $ts_a; } ); $total_items = count( $grouped_items ); $page = max( 1, (int) $args['page'] ); $per_page = max( 1, min( 200, (int) $args['per_page'] ) ); $total_pages = (int) ceil( $total_items / $per_page ); $offset = ( $page - 1 ) * $per_page; $items = array_slice( $grouped_items, $offset, $per_page ); return array( 'items' => $items, 'total_items' => $total_items, 'total_issues' => count( $filtered ), 'total_pages' => $total_pages, 'page' => $page, 'per_page' => $per_page, ); } /** * Detect origin source from file path (Plugin, Theme, WordPress Core, or Other). * * @param string $file * @return string 'Plugin'|'Theme'|'WordPress Core'|'Other' */ public static function detect_source( string $file ): string { if ( empty( $file ) ) { return 'Other'; } $norm = str_replace( '\\', '/', strtolower( $file ) ); if ( false !== strpos( $norm, '/wp-content/plugins/' ) || false !== strpos( $norm, '/wp-content/mu-plugins/' ) ) { return 'Plugin'; } if ( false !== strpos( $norm, '/wp-content/themes/' ) ) { return 'Theme'; } if ( false !== strpos( $norm, '/wp-includes/' ) || false !== strpos( $norm, '/wp-admin/' ) ) { return 'WordPress Core'; } return 'Other'; } /** * Get numeric rank for error type (1 = most critical). * Order: Fatal Errors (1) -> Errors / Exceptions (2) -> Warnings (3) -> Notices (4) -> Deprecated (5). * * @param string $error_type * @return int */ public function get_severity_rank( string $error_type ): int { $type = strtolower( $error_type ); if ( false !== strpos( $type, 'fatal' ) || false !== strpos( $type, 'parse' ) ) { return 1; } if ( false !== strpos( $type, 'exception' ) || false !== strpos( $type, 'error' ) ) { return 2; } if ( false !== strpos( $type, 'warning' ) ) { return 3; } if ( false !== strpos( $type, 'notice' ) ) { return 4; } if ( false !== strpos( $type, 'deprecated' ) ) { return 5; } return 6; } /** * Get active runtime logs formatted for export. * * @param array $args * @return array */ public function get_logs_for_export( array $args = array() ): array { $args['per_page'] = 5000; $args['page'] = 1; $result = $this->get_logs( $args ); // Flatten grouped items to include all individual issues from each file. $flat = array(); foreach ( $result['items'] as $item ) { $flat[] = $item; if ( ! empty( $item->other_issues ) && is_array( $item->other_issues ) ) { foreach ( $item->other_issues as $sibling ) { $flat[] = (object) $sibling; } } } return $flat; } /** * Get active runtime logs since a given datetime (for email digests). * * @param string $since * @param int $limit * @return array */ public function get_logs_since( string $since = '', int $limit = 1000 ): array { $args = array( 'per_page' => $limit, 'page' => 1, ); if ( ! empty( $since ) ) { $args['start_date'] = $since; } $result = $this->get_logs( $args ); // Flatten grouped items to include all individual issues from each file. $flat = array(); foreach ( $result['items'] as $item ) { $flat[] = $item; if ( ! empty( $item->other_issues ) && is_array( $item->other_issues ) ) { foreach ( $item->other_issues as $sibling ) { $flat[] = (object) $sibling; } } } return $flat; } /** * Get a single runtime log entry by ID, including organized sibling issues from the same file. * * @param int $id Log ID. * @return object|null */ public function get_log( int $id ) { $this->prune_resolved_errors(); $logs = $this->load_runtime_logs(); $target_item = null; $target_key = ''; // First, find the requested target item by ID. foreach ( $logs as $item ) { if ( isset( $item['id'] ) && (int) $item['id'] === $id ) { $target_item = $item; $file_path = $item['file'] ?? ''; $norm_file = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $file_path ) : str_replace( '\\', '/', $file_path ); $target_key = strtolower( trim( $norm_file ) ); break; } } // Fallback: If not matched by ID, try first item. if ( ! $target_item && ! empty( $logs ) ) { $target_item = reset( $logs ); $file_path = $target_item['file'] ?? ''; $norm_file = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $file_path ) : str_replace( '\\', '/', $file_path ); $target_key = strtolower( trim( $norm_file ) ); } if ( ! $target_item || empty( $target_key ) ) { return null; } // Collect ALL items belonging to this exact file group. $file_items = array(); foreach ( $logs as $item ) { $file_path = $item['file'] ?? ''; $norm_file = function_exists( 'wp_normalize_path' ) ? wp_normalize_path( $file_path ) : str_replace( '\\', '/', $file_path ); $file_key = strtolower( trim( $norm_file ) ); if ( $file_key === $target_key ) { $item['source'] = self::detect_source( $item['file'] ?? '' ); $file_items[] = $item; } } // Sort all items in this file by severity rank, then timestamp DESC. usort( $file_items, function( $a, $b ) { $rank_a = $this->get_severity_rank( $a['error_type'] ?? '' ); $rank_b = $this->get_severity_rank( $b['error_type'] ?? '' ); if ( $rank_a !== $rank_b ) { return $rank_a <=> $rank_b; } $ts_a = $a['timestamp'] ?? 0; $ts_b = $b['timestamp'] ?? 0; return $ts_b <=> $ts_a; } ); // The top ranked issue is Primary (Error #1). $primary = $file_items[0]; $primary['issue_number'] = 1; $primary['file_issues_count'] = count( $file_items ); // Sibling issues are Error #2, Error #3, etc. $siblings = array(); for ( $i = 1; $i < count( $file_items ); $i++ ) { $sibling = $file_items[ $i ]; $sibling['issue_number'] = $i + 1; $siblings[] = $sibling; } $primary['sibling_issues'] = $siblings; return (object) $primary; } /** * Get active runtime error counts categorized by severity. * * @param string $since Optional timestamp. * @return array */ public function get_statistics( string $since = '' ): array { if ( empty( $since ) && null !== $this->stats_cache ) { return $this->stats_cache; } $this->prune_resolved_errors(); $logs = $this->load_runtime_logs(); $since_ts = null; if ( ! empty( $since ) ) { $parsed = strtotime( $since ); if ( false !== $parsed ) { $since_ts = $parsed; } } $stats = array( 'total' => 0, 'fatal' => 0, 'error' => 0, 'warning' => 0, 'notice' => 0, 'deprecated' => 0, 'exception' => 0, ); foreach ( $logs as $item ) { // Filter by since timestamp when provided. if ( null !== $since_ts ) { $item_time = isset( $item['timestamp'] ) && is_numeric( $item['timestamp'] ) ? (int) $item['timestamp'] : 0; if ( ! $item_time && ! empty( $item['last_seen'] ) ) { $item_time = (int) strtotime( $item['last_seen'] ); } if ( ! $item_time && ! empty( $item['created_at'] ) ) { $item_time = (int) strtotime( $item['created_at'] ); } if ( $item_time < $since_ts ) { continue; } } $type = strtolower( $item['error_type'] ?? '' ); $stats['total']++; if ( false !== strpos( $type, 'fatal' ) ) { $stats['fatal']++; } elseif ( false !== strpos( $type, 'warning' ) ) { $stats['warning']++; } elseif ( false !== strpos( $type, 'notice' ) ) { $stats['notice']++; } elseif ( false !== strpos( $type, 'deprecated' ) ) { $stats['deprecated']++; } elseif ( false !== strpos( $type, 'exception' ) ) { $stats['exception']++; } else { $stats['error']++; } } if ( empty( $since ) ) { $this->stats_cache = $stats; } return $stats; } /** * Clear all runtime logs. * * @return bool */ public function clear_all_logs(): bool { return $this->save_runtime_logs( array() ); } /** * Delete a single log entry by ID. * * @param int $id * @return bool */ public function delete_log( int $id ): bool { $logs = $this->load_runtime_logs(); $changed = false; foreach ( $logs as $hash => $item ) { if ( isset( $item['id'] ) && (int) $item['id'] === $id ) { unset( $logs[ $hash ] ); $changed = true; break; } } return $changed ? $this->save_runtime_logs( $logs ) : false; } /** * Delete multiple log entries by IDs. * * @param array $ids * @return int */ public function delete_logs( array $ids ): int { $logs = $this->load_runtime_logs(); $deleted = 0; foreach ( $logs as $hash => $item ) { if ( isset( $item['id'] ) && in_array( (int) $item['id'], $ids, true ) ) { unset( $logs[ $hash ] ); $deleted++; } } if ( $deleted > 0 ) { $this->save_runtime_logs( $logs ); } return $deleted; } /** * No-op table check for backwards compatibility. * * @return void */ public function maybe_create_table(): void { $this->drop_legacy_table(); } /** * Prune old logs for cron compatibility. * * @param int $days * @return int */ public function prune_old_logs( int $days = 30 ): int { $before = count( $this->load_runtime_logs() ); $this->prune_resolved_errors( $days ); $after = count( $this->load_runtime_logs() ); return max( 0, $before - $after ); } }