| | |
| | | class Errors |
| | | { |
| | | use Instance; |
| | | protected int $notification_threshold = 5; // Critical errors within 1 hour |
| | | |
| | | protected array $error_levels = [ |
| | | 'debug' => 0, |
| | | 'info' => 1, |
| | | 'warning' => 2, |
| | | 'error' => 3, |
| | | 'critical' => 4 |
| | | ]; |
| | | |
| | | protected CustomTable $table; |
| | | |
| | | private function __construct() |
| | | { |
| | | $this->defineTables(); |
| | | // global $wpdb; |
| | | // $this->wpdb = $wpdb; |
| | | // $this->tableName = $wpdb->prefix . BASE . 'error_log'; |
| | | |
| | | add_filter(BASE.'handle_bulk_operation', [$this, 'processOperation'], 10, 3); |
| | | |
| | | // Hook our send function to the cron event |
| | | add_action('jvb_daily_error_summary', [$this, 'sendErrorSummary']); |
| | | |
| | | jvb_register_do_once('error_admin_action_registered', [$this, 'registerAdminAction']); |
| | | // add_filter(BASE.'admin_action_filter', [$this, 'adminActionFilter'], 10, 3); |
| | | } |
| | | |
| | | public function defineTables():void |
| | | /** |
| | | * @param string $message |
| | | * @param array $context |
| | | * @return void |
| | | */ |
| | | public static function log(string $message, array $context = []):void |
| | | { |
| | | $table = CustomTable::for('error_log'); |
| | | $table->setColumns([ |
| | | 'id' => 'bigint(20) unsigned NOT NULL AUTO_INCREMENT', |
| | | 'error_type' => 'varchar(50) NOT NULL', |
| | | 'component' => 'varchar(100) NOT NULL', |
| | | 'method' => 'varchar(100) DEFAULT NULL', |
| | | 'page_url' => 'varchar(255) DEFAULT NULL', |
| | | 'message' => 'text NOT NULL', |
| | | 'context' => 'JSON', |
| | | 'severity' => 'ENUM(\'high\',\'normal\',\'low\') DEFAULT \'normal\'', |
| | | 'user_id' => $table->getUserIDType().' DEFAULT NULL', |
| | | 'user_was_logged_in' => 'tinyint(1) NOT NULL', |
| | | 'source' => 'ENUM(\'frontend\', \'backend\') NOT NULL', |
| | | 'created_at' => 'timestamp DEFAULT CURRENT_TIMESTAMP', |
| | | ]); |
| | | |
| | | $table->setKeys([ |
| | | ['key' => 'PRIMARY', 'value' => 'id'], |
| | | '`created_at` (`created_at`)', |
| | | '`component_severity_date` (`component`, `severity`, `created_at`)', |
| | | '`error_type_date` (`error_type`, `created_at`)', |
| | | '`severity_date` (`severity`, `created_at`)' |
| | | ]); |
| | | |
| | | $table->defineTable(); |
| | | $this->table = $table; |
| | | } |
| | | public function registerAdminAction():void |
| | | { |
| | | $admin = AdminPages::getInstance(); |
| | | |
| | | $admin->registerSubpage( |
| | | 'Error Log', |
| | | 'Errors', |
| | | 'manage_options', |
| | | 'jvb-error-log', |
| | | [$this, 'renderAdminPage'], |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Process error log operations queued through OperationQueue |
| | | * |
| | | * @param WP_Error|array $result The result we are filtering |
| | | * @param object $operation The operation information |
| | | * @param array $data The current chunk of data to process |
| | | * |
| | | * @return WP_Error|array Either the unfiltered WP_Error, or the result of the operation |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data):WP_Error|array |
| | | { |
| | | if ($operation->type !== 'error_log') { |
| | | return $result; |
| | | } |
| | | |
| | | try { |
| | | |
| | | // Extract error data |
| | | $component = sanitize_text_field($data['component'] ?? ''); |
| | | $message = sanitize_textarea_field($data['message'] ?? ''); |
| | | $context = $data['context'] ?? []; |
| | | $severity = sanitize_text_field($data['severity'] ?? 'error'); |
| | | |
| | | // Validate severity level |
| | | if (!array_key_exists($severity, $this->error_levels)) { |
| | | $severity = 'error'; // Default to error if invalid severity provided |
| | | } |
| | | |
| | | // Determine error type from context or use component as fallback |
| | | $error_type = sanitize_text_field($context['error_type'] ?? $component); |
| | | |
| | | // Store context as JSON |
| | | $context_json = json_encode($context); |
| | | if (json_last_error() !== JSON_ERROR_NONE) { |
| | | // If JSON encoding fails, store only basic info |
| | | $context_json = json_encode([ |
| | | 'error' => 'Failed to encode original context', |
| | | 'json_error' => json_last_error_msg() |
| | | ]); |
| | | } |
| | | |
| | | // Insert into database |
| | | $result = $this->table->insert( |
| | | [ |
| | | 'error_type' => $error_type, |
| | | 'component' => $component, |
| | | 'message' => $message, |
| | | 'context' => $context_json, |
| | | 'severity' => $severity, |
| | | 'user_id' => get_current_user_id(), |
| | | 'created_at' => current_time('mysql') |
| | | ] |
| | | ); |
| | | |
| | | if (!$result) { |
| | | // If insert fails, log to PHP error log as fallback |
| | | error_log("[ErrorHandler] Database insert failed: " . $this->table->getLastError()); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => "[ErrorHandler] Database insert failed: " . $this->table->getLastError() |
| | | ]; |
| | | } |
| | | |
| | | // Potentially track error metrics or trigger additional actions for critical errors |
| | | if ($severity === 'critical') { |
| | | $this->checkErrorThreshold($error_type, $component); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => $result |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | // Fallback to PHP error log if our error handler itself fails |
| | | error_log("[ErrorHandler Exception] " . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check if error threshold has been reached for a specific error type/component |
| | | * and take additional actions if needed |
| | | * |
| | | * @param string $error_type The type of error |
| | | * @param string $component The component where the error occurred |
| | | */ |
| | | protected function checkErrorThreshold(string $error_type, string $component) |
| | | { |
| | | // Get count of similar critical errors in the last hour |
| | | $count = $this->table->count([ |
| | | 'error_type' => $error_type, |
| | | 'component' => $component, |
| | | 'severity' => $this->error_levels[$error_type], |
| | | 'created_at' => ['>','DATE_SUB(NOW(), INTERVAL 1 HOUR)'] |
| | | ]); |
| | | |
| | | // If threshold reached, take additional actions (e.g., notify developers) |
| | | if ($count >= $this->notification_threshold) { |
| | | // You could send an urgent notification, Slack message, etc. |
| | | $admin_email = get_option('admin_email'); |
| | | $subject = "[URGENT] Error Threshold Exceeded for {$component}"; |
| | | $message = "The error '{$error_type}' in component '{$component}' has occurred {$count} times in the last hour."; |
| | | |
| | | wp_mail($admin_email, $subject, $message); |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * @param string $component Where the error is being logged from |
| | | * @param string $message The error message, usually extracted from $e->getMessage()) |
| | | * @param array $context Additional context (i.e: data that was attempted to be acted on) |
| | | * @param string $severity |
| | | * |
| | | * @return bool Whether it gets logged successfully |
| | | */ |
| | | public function log(string $component, string $message, array $context = [], string $severity = 'error'): array |
| | | { |
| | | try { |
| | | // Validate severity |
| | | if (!array_key_exists($severity, $this->error_levels)) { |
| | | $severity = 'error'; |
| | | } |
| | | |
| | | // Extract info |
| | | $error_type = sanitize_text_field($context['error_type'] ?? $component); |
| | | $method = isset($context['method']) ? sanitize_text_field($context['method']) : null; |
| | | $page_url = isset($context['url']) ? esc_url_raw($context['url']) : null; |
| | | $user_id = get_current_user_id(); |
| | | $user_was_logged_in = $user_id > 0 || (!empty($context['isLoggedIn'])); |
| | | |
| | | // Determine source from context |
| | | $source = isset($context['source']) ? $context['source'] : |
| | | (isset($context['url']) ? 'frontend' : 'backend'); |
| | | |
| | | $result = $this->table->insert( |
| | | [ |
| | | 'error_type' => $error_type, |
| | | 'component' => $component, |
| | | 'method' => $method, |
| | | 'page_url' => $page_url, |
| | | 'message' => sanitize_textarea_field($message), |
| | | 'context' => json_encode($context), |
| | | 'severity' => $severity, |
| | | 'user_id' => $user_id ?: null, |
| | | 'user_was_logged_in' => $user_was_logged_in ? 1 : 0, |
| | | 'source' => $source, |
| | | 'created_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | if (!$result) { |
| | | error_log("[ErrorHandler] Database insert failed: " . $this->table->getLastError()); |
| | | return ['success' => false, 'message' => $this->table->getLastError()]; |
| | | } |
| | | |
| | | if ($severity === 'critical') { |
| | | $this->checkErrorThreshold($error_type, $component); |
| | | } |
| | | |
| | | return ['success' => true, 'id' => $result]; |
| | | |
| | | } catch (Exception $e) { |
| | | error_log("[ErrorHandler Exception] " . $e->getMessage()); |
| | | return ['success' => false, 'message' => $e->getMessage()]; |
| | | $msg = '{'.BASE.'}'; |
| | | if (array_key_exists('class', $context)) { |
| | | $msg .= '['.$context['class'].']'; |
| | | unset($context['class']); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * @param string $component What class or function logs the error |
| | | * @param string $message Details on the error |
| | | * @param array $context additional context of the data that was being processed |
| | | * |
| | | * @return bool Whether the notification is sent successfully |
| | | */ |
| | | protected function notifyAdmin(string $component, string $message, array $context):bool |
| | | { |
| | | $admin_email = get_option('admin_email'); |
| | | $subject = "[" . get_bloginfo('name') . " Critical Error] {$component}"; |
| | | |
| | | $body = Emails::getInstance()->alert( |
| | | 'A critical error has occurred and requires immediate attention', |
| | | 'error' |
| | | ); |
| | | |
| | | $body .= Emails::getInstance()->h2('Error Details'); |
| | | $body .= Emails::getInstance()->card( |
| | | '<p><strong>Component:</strong> ' . esc_html($component) . '</p>' . |
| | | '<p><strong>Message:</strong></p>' . |
| | | Emails::getInstance()->codeBlock($message), |
| | | 'Error Information' |
| | | ); |
| | | |
| | | if (array_key_exists('method', $context)) { |
| | | $msg .= '::'.$context['method']; |
| | | unset($context['method']); |
| | | } |
| | | if (array_key_exists('function', $context)) { |
| | | $msg .= $context['function']; |
| | | unset($context['function']); |
| | | } |
| | | error_log($msg.' '.$message); |
| | | if (!empty($context)) { |
| | | $body .= Emails::getInstance()->h3('Additional Context'); |
| | | $body .= Emails::getInstance()->codeBlock(json_encode($context, JSON_PRETTY_PRINT)); |
| | | error_log('Context: '.print_r($context, true)); |
| | | } |
| | | |
| | | return Emails::getInstance()->sendEmail($admin_email, $subject, $body, 'CRITICAL ERROR'); |
| | | } |
| | | |
| | | /** |
| | | * Gather summary of the most important errors |
| | | * @param ?string $start_date Defaults to today |
| | | * @param ?string $end_date Defaults to today |
| | | * @return array |
| | | */ |
| | | public function gatherErrorSummary(?string $start_date = null, ?string $end_date = null): array |
| | | { |
| | | if (!$start_date) { |
| | | $start_date = gmdate('Y-m-d 00:00:00', strtotime('-1 day')); |
| | | } |
| | | if (!$end_date) { |
| | | $end_date = gmdate('Y-m-d 23:59:59'); |
| | | } |
| | | |
| | | // Most frequent error patterns (deduplicated by component/method/message) |
| | | $frequent = $this->table->getMany([ |
| | | 'where' => [ |
| | | 'created_at' => ['BETWEEN', "{$start_date} AND {$end_date}"] |
| | | ] |
| | | ]); |
| | | |
| | | return [ |
| | | 'errors' => $frequent, |
| | | 'date_range' => ['start' => $start_date, 'end' => $end_date] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Send daily error summary email to administrator |
| | | * @return bool Whether email is sent |
| | | */ |
| | | public function sendErrorSummary():bool |
| | | { |
| | | $summary = $this->gatherErrorSummary(); |
| | | |
| | | if (empty($summary['frequent']) && empty($summary['critical'])) { |
| | | return false; |
| | | } |
| | | |
| | | $admin_email = get_option('admin_email'); |
| | | $site_name = get_bloginfo('name'); |
| | | $yesterday = date('Y-m-d', strtotime('-1 day')); |
| | | $subject = "[{$site_name}] Daily Error Summary - " . date('Y-m-d'); |
| | | |
| | | // Header with alert |
| | | $body = Emails::getInstance()->h1('Daily Error Summary'); |
| | | $body .= sprintf('<p>Error summary for <strong>%s</strong></p>', $yesterday); |
| | | |
| | | // Summary stats in a grid |
| | | if (!empty($summary['stats'])) { |
| | | $stats = [ |
| | | Emails::getInstance()->stat($summary['stats']->total_errors, 'Total Errors'), |
| | | Emails::getInstance()->stat($summary['stats']->critical_count, 'Critical', 'Requires attention'), |
| | | Emails::getInstance()->stat($summary['stats']->error_count, 'Errors'), |
| | | Emails::getInstance()->stat($summary['stats']->warning_count, 'Warnings') |
| | | ]; |
| | | $body .= Emails::getInstance()->grid($stats, 4); |
| | | } |
| | | |
| | | // Alert if critical errors exist |
| | | if (!empty($summary['critical'])) { |
| | | $body .= Emails::getInstance()->alert( |
| | | sprintf('Found %d critical errors that need immediate attention', count($summary['critical'])), |
| | | 'error' |
| | | ); |
| | | } |
| | | |
| | | $body .= Emails::getInstance()->spacer(20); |
| | | |
| | | // Frequent errors section |
| | | if (!empty($summary['frequent'])) { |
| | | $body .= Emails::getInstance()->h2('Most Frequent Errors'); |
| | | |
| | | foreach ($summary['frequent'] as $error) { |
| | | $cardContent = Emails::getInstance()->badge($error->count . 'x', 'warning') . ' '; |
| | | $cardContent .= '<strong>' . esc_html($error->error_type) . '</strong>'; |
| | | $cardContent .= '<p style="margin:10px 0 5px 0;font-size:13px;">' . esc_html(wp_trim_words($error->message, 15)) . '</p>'; |
| | | $cardContent .= '<p style="margin:0;font-size:12px;color:' . Emails::getInstance()->colours['dark-200'] . ';"> |
| | | Source: ' . esc_html($error->source) . ' | |
| | | Logged in: ' . $error->logged_in_count . ' | |
| | | Logged out: ' . $error->logged_out_count . ' |
| | | </p>'; |
| | | |
| | | $body .= Emails::getInstance()->card($cardContent, $error->component); |
| | | } |
| | | } |
| | | |
| | | // Critical errors section |
| | | if (!empty($summary['critical'])) { |
| | | $body .= Emails::getInstance()->spacer(30); |
| | | $body .= Emails::getInstance()->h2('Recent Critical Errors'); |
| | | |
| | | foreach ($summary['critical'] as $error) { |
| | | $cardContent = '<p><strong>Time:</strong> ' . esc_html($error->created_at) . '</p>'; |
| | | $cardContent .= '<p><strong>Message:</strong></p>'; |
| | | $cardContent .= Emails::getInstance()->codeBlock($error->message); |
| | | |
| | | // Include context if available |
| | | if (!empty($error->context)) { |
| | | $context = json_decode($error->context, true); |
| | | if (is_array($context)) { |
| | | $cardContent .= '<p><strong>Context:</strong></p>'; |
| | | $cardContent .= Emails::getInstance()->codeBlock(json_encode($context, JSON_PRETTY_PRINT)); |
| | | } |
| | | } |
| | | |
| | | $body .= Emails::getInstance()->card($cardContent, $error->component . ': ' . $error->error_type); |
| | | } |
| | | } |
| | | |
| | | // Dashboard link |
| | | $admin_url = admin_url('admin.php?page=jvb-error-logs'); |
| | | $body .= Emails::getInstance()->spacer(30); |
| | | $body .= Emails::getInstance()->button($admin_url, 'View Detailed Logs'); |
| | | |
| | | return Emails::getInstance()->sendEmail($admin_email, $subject, $body, 'ERROR SUMMARY'); |
| | | } |
| | | |
| | | /** |
| | | * Get HTML version of the error summary for nicer emails |
| | | * |
| | | * @param array $summary The error summary data |
| | | * @return string HTML formatted error summary |
| | | */ |
| | | protected function getHtmlErrorSummary(array $summary):string |
| | | { |
| | | $html = '<div style="font-family: sans-serif; max-width: 800px; margin: 0 auto;">'; |
| | | $html .= '<h1 style="color:#FF0080;border-bottom:2px solid #1B1B1B;padding-bottom:10px;">Error Summary</h1>'; |
| | | |
| | | // Frequent errors table |
| | | if (!empty($summary['frequent'])) { |
| | | $html .= '<h2>Most Frequent Errors</h2>'; |
| | | $html .= '<table style="width: 100%; border-collapse: collapse; margin-bottom: 20px;">'; |
| | | $html .= '<tr style="background-color: #1B1B1B; color: #EFEFEF;">'; |
| | | $html .= '<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Component</th>'; |
| | | $html .= '<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Error Type</th>'; |
| | | $html .= '<th style="padding: 8px; text-align: left; border: 1px solid #ddd;">Message</th>'; |
| | | $html .= '<th style="padding: 8px; text-align: center; border: 1px solid #ddd;">Count</th>'; |
| | | $html .= '</tr>'; |
| | | |
| | | foreach ($summary['frequent'] as $error) { |
| | | $html .= '<tr>'; |
| | | $html .= '<td style="padding:8px;border:1px solid #ddd;">' . esc_html($error->component) . '</td>'; |
| | | $html .= '<td style="padding:8px;border:1px solid #ddd;">' . esc_html($error->error_type) . '</td>'; |
| | | $html .= '<td style="padding:8px;border:1px solid #ddd;">' . esc_html(wp_trim_words($error->message, 10, '...')) . '</td>'; |
| | | $html .= '<td style="padding:8px;border:1px solid #ddd;text-align:center;"><strong>' . esc_html($error->count) . '</strong></td>'; |
| | | $html .= '</tr>'; |
| | | } |
| | | |
| | | $html .= '</table>'; |
| | | } |
| | | |
| | | // Critical errors section |
| | | if (!empty($summary['critical'])) { |
| | | $html .= '<h2>Recent Critical Errors</h2>'; |
| | | |
| | | foreach ($summary['critical'] as $error) { |
| | | $html .= '<div style="margin-bottom: 20px; padding: 15px; border-left: 4px solid #FF0080; background-color: #fff9fb;">'; |
| | | $html .= '<h3 style="margin-top: 0; color: #1B1B1B;">' . esc_html($error->component) . ': ' . esc_html($error->error_type) . '</h3>'; |
| | | $html .= '<p><strong>Time:</strong> ' . esc_html($error->created_at) . '</p>'; |
| | | $html .= '<p><strong>Message:</strong> ' . esc_html($error->message) . '</p>'; |
| | | |
| | | // Context display |
| | | if (!empty($error->context)) { |
| | | $context = json_decode($error->context, true); |
| | | if (is_array($context)) { |
| | | $html .= '<div style="margin-top: 10px;">'; |
| | | $html .= '<strong>Context:</strong>'; |
| | | $html .= '<pre style="background-color: #f5f5f5; padding: 10px; border-radius: 4px; overflow: auto; max-height: 200px;">'; |
| | | $html .= esc_html(json_encode($context, JSON_PRETTY_PRINT)); |
| | | $html .= '</pre>'; |
| | | $html .= '</div>'; |
| | | } |
| | | } |
| | | |
| | | $html .= '</div>'; |
| | | } |
| | | } |
| | | |
| | | // Dashboard link |
| | | $admin_url = admin_url('admin.php?page=jvb-error-logs'); |
| | | $html .= '<p style="margin-top: 30px; padding-top: 15px; border-top: 1px solid #ddd;">'; |
| | | $html .= '<a href="' . esc_url($admin_url) . '" style="display: inline-block; background-color: #FF0080; color: white; padding: 10px 15px; text-decoration: none; border-radius: 4px;">View All Errors in Dashboard</a>'; |
| | | $html .= '</p>'; |
| | | |
| | | $html .= '</div>'; |
| | | |
| | | return $html; |
| | | } |
| | | |
| | | public function renderAdminPage() |
| | | { |
| | | ?> |
| | | <h1>Error Logs</h1> |
| | | <?php |
| | | } |
| | | |
| | | public function getErrors(WP_REST_Request $request) { |
| | | $params = $this->buildParams($request); |
| | | |
| | | } |
| | | |
| | | |
| | | |
| | | protected function buildParams(WP_REST_Request $request):array { |
| | | $allowedSeverity = [ |
| | | 'all', |
| | | 'notice', |
| | | 'warning', |
| | | 'error', |
| | | 'critical', |
| | | ]; |
| | | $allowedComponents = [ |
| | | 'all', |
| | | //Get all components |
| | | ]; |
| | | $severity = $request->get_param('severity')??'all'; |
| | | $component = $request->get_param('component')??'all'; |
| | | |
| | | return [ |
| | | 'page' => (int)$request->get_param('page'), |
| | | 'severity' => (in_array($severity, $allowedSeverity)) ? $severity : 'all', |
| | | 'component' => (in_array($component, $allowedComponents)) ? $component : 'all', |
| | | ]; |
| | | } |
| | | } |