*/ // If this file is called directly, abort. if (!defined('WPINC')) { die; } /** * Enhanced Error Logger Class * * Provides structured error logging with: * - 8 business logic error categories * - Standardized error codes (MS-XXXX format) * - Structured log format with JSON context * - WordPress action hooks for external monitoring * - Error summary tracking in wp_options */ class Metasync_Error_Logger { /** * Error category constants */ const CATEGORY_API_RATE_LIMIT = 'API_RATE_LIMIT'; const CATEGORY_API_BACKOFF = 'API_BACKOFF'; const CATEGORY_EXECUTION_TIMEOUT = 'EXECUTION_TIMEOUT'; const CATEGORY_MEMORY_EXHAUSTED = 'MEMORY_EXHAUSTED'; const CATEGORY_QUEUE_OVERFLOW = 'QUEUE_OVERFLOW'; const CATEGORY_AUTHENTICATION_FAILURE = 'AUTHENTICATION_FAILURE'; const CATEGORY_DATABASE_ERROR = 'DATABASE_ERROR'; const CATEGORY_NETWORK_ERROR = 'NETWORK_ERROR'; const CATEGORY_OTTO_RENDER = 'OTTO_RENDER'; /** * Error codes mapping * Format: MS-XXXX */ private static $error_codes = [ self::CATEGORY_API_RATE_LIMIT => 'MS-1001', self::CATEGORY_API_BACKOFF => 'MS-1002', self::CATEGORY_EXECUTION_TIMEOUT => 'MS-2001', self::CATEGORY_MEMORY_EXHAUSTED => 'MS-2002', self::CATEGORY_QUEUE_OVERFLOW => 'MS-3001', self::CATEGORY_AUTHENTICATION_FAILURE => 'MS-4001', self::CATEGORY_DATABASE_ERROR => 'MS-5001', self::CATEGORY_NETWORK_ERROR => 'MS-6001', self::CATEGORY_OTTO_RENDER => 'MS-7001', ]; /** * Categories that only exist while an operator is troubleshooting. * * These record the plugin's OWN client-side throttling, not a site fault: * the OTTO call budget was reached, or a retry was deferred. Both are the * plugin working as designed, but they read as failures in the Error Logs * panel and are a known source of false-alarm bug reports. They are only * recorded while Debug Mode is on, and only shown while Debug Mode is on. */ const DEBUG_ONLY_CATEGORIES = [ self::CATEGORY_API_RATE_LIMIT, self::CATEGORY_API_BACKOFF, ]; /** * Non-alarming labels for categories whose raw name overstates severity. * * Presentation only — the stored category is never rewritten, so historical * rows render softly too and no data migration is needed. */ private static $display_labels = [ self::CATEGORY_API_RATE_LIMIT => 'THROTTLED', self::CATEGORY_API_BACKOFF => 'RETRY_SCHEDULED', ]; /** * Severity level constants */ const SEVERITY_INFO = 'INFO'; const SEVERITY_WARNING = 'WARNING'; const SEVERITY_ERROR = 'ERROR'; const SEVERITY_CRITICAL = 'CRITICAL'; /** * Option name for error summary storage */ const ERROR_SUMMARY_OPTION = 'metasync_error_summary'; /** * Maximum number of unique errors to keep in summary */ const MAX_SUMMARY_ENTRIES = 100; /** * Log file name */ const LOG_FILE_NAME = 'metasync-errors.log'; /** * Main logging function * * Formats and writes structured error log with: * - Timestamp * - Category * - Severity * - Message * - JSON context * * Also: * - Fires WordPress action hook * - Updates error summary in wp_options * * @param string $category One of the CATEGORY_* constants * @param string $severity One of the SEVERITY_* constants * @param string $message Human-readable error message * @param array $context Additional context data (optional) * @return bool True on success, false on failure */ public static function log($category, $severity, $message, $context = []) { // Validate inputs if (empty($category) || empty($severity) || empty($message)) { return false; } // Throttling categories are diagnostics, not faults: skip them entirely // unless an operator has Debug Mode on. Nothing is written, no summary // entry is created, and the metasync_error_logged action does not fire. if (self::is_suppressed_category($category)) { return false; } // Get error code for this category $error_code = self::$error_codes[$category] ?? 'MS-0000'; // Prepare full context with error code $full_context = array_merge([ 'error_code' => $error_code ], $context); // Format timestamp $timestamp = date('Y-m-d H:i:s'); // Format log line: [YYYY-MM-DD HH:MM:SS] [CATEGORY] [SEVERITY] Message {context_json} $log_line = sprintf( "[%s] [%s] [%s] %s %s\n", $timestamp, $category, $severity, $message, json_encode($full_context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) ); // Write to dedicated log file $log_written = self::write_to_log_file($log_line); // Fire WordPress action hook for external monitoring do_action('metasync_error_logged', $category, $error_code, $message, $full_context); // Update error summary in database self::update_error_summary($category, $error_code, $message, $full_context); return $log_written; } /** * Write log line to dedicated error log file * * @param string $log_line Formatted log line * @return bool True on success, false on failure */ private static function write_to_log_file($log_line) { // Get log directory (same as Log_Manager uses) $log_directory = WP_CONTENT_DIR . '/metasync_data'; // Create directory if it doesn't exist if (!is_dir($log_directory)) { if (!@mkdir($log_directory, 0755, true)) { error_log('Metasync_Error_Logger: Failed to create log directory: ' . $log_directory); return false; } } // Protect log directory from direct web access $htaccess_file = $log_directory . '/.htaccess'; if (!file_exists($htaccess_file)) { @file_put_contents($htaccess_file, "Order deny,allow\nDeny from all\n"); } $index_file = $log_directory . '/index.php'; if (!file_exists($index_file)) { @file_put_contents($index_file, " $category, 'code' => $code, 'message' => $message, 'count' => 0, 'first_seen' => current_time('mysql'), 'last_seen' => current_time('mysql'), 'severity' => isset($context['severity']) ? $context['severity'] : 'UNKNOWN' ]; } // Update count and last seen time $summary[$key]['count']++; $summary[$key]['last_seen'] = current_time('mysql'); // Keep only last 100 unique errors (prune oldest) if (count($summary) > self::MAX_SUMMARY_ENTRIES) { // Sort by last_seen (newest first) uasort($summary, function($a, $b) { return strtotime($b['last_seen']) - strtotime($a['last_seen']); }); // Keep only the most recent entries $summary = array_slice($summary, 0, self::MAX_SUMMARY_ENTRIES, true); } // Save to database update_option(self::ERROR_SUMMARY_OPTION, $summary); } /** * Get error summary from wp_options * * @return array Error summary array */ public static function get_error_summary() { $summary = get_option(self::ERROR_SUMMARY_OPTION, []); return is_array($summary) ? $summary : []; } /** * Clear error summary * * @return bool True on success */ public static function clear_error_summary() { return delete_option(self::ERROR_SUMMARY_OPTION); } /** * Whether a category is withheld on this request. * * True for the throttling categories while Debug Mode is off. Used both to * skip recording and to filter what the Error Logs panel shows, so rows * recorded before this behaviour existed are hidden too. * * @param string $category Category name * @return bool True when the category should be withheld */ public static function is_suppressed_category($category) { if (!in_array($category, self::DEBUG_ONLY_CATEGORIES, true)) { return false; } return !(class_exists('Metasync_Debug_Mode_Manager') && Metasync_Debug_Mode_Manager::is_enabled()); } /** * Non-alarming label to display for a category. * * @param string $category Stored category name * @return string Label for display */ public static function get_display_label($category) { return self::$display_labels[$category] ?? $category; } /** * Get error summary for display, with withheld categories removed. * * @return array Error summary entries safe to show on this request */ public static function get_visible_error_summary() { // get_error_summary() already guarantees an array. $summary = self::get_error_summary(); foreach ($summary as $key => $entry) { $category = is_array($entry) ? ($entry['category'] ?? '') : ''; if ($category !== '' && self::is_suppressed_category($category)) { unset($summary[$key]); } } return $summary; } /** * Get error code for a category * * @param string $category Error category * @return string Error code or 'MS-0000' if not found */ public static function get_error_code($category) { return self::$error_codes[$category] ?? 'MS-0000'; } /** * Get all error codes mapping * * @return array Error codes array */ public static function get_all_error_codes() { return self::$error_codes; } } /** * Check Action Scheduler queue for overflow * This checks if pending actions exceed 1000 and logs QUEUE_OVERFLOW error * * @param bool $force_check If true, bypasses transient throttling (for manual testing) * @return int|false Returns pending count if checked, false if skipped */ function metasync_check_action_scheduler_queue_overflow($force_check = false) { // Only check if Action Scheduler is available if (!class_exists('ActionScheduler_Store')) { return false; } // Only check if Error Logger is available if (!class_exists('Metasync_Error_Logger')) { return false; } // Throttle: Only check once per hour to avoid excessive logging (unless forced) if (!$force_check) { $transient_key = 'metasync_queue_overflow_check'; $last_check = get_transient($transient_key); if ($last_check !== false) { return false; // Already checked recently } } try { $store = ActionScheduler_Store::instance(); // Get count of pending actions $pending_count = (int) $store->query_actions([ 'status' => ActionScheduler_Store::STATUS_PENDING, 'per_page' => 0, // We only need count ], 'count'); // Check if queue overflow threshold is exceeded (>1000) if ($pending_count > 1000) { Metasync_Error_Logger::log( Metasync_Error_Logger::CATEGORY_QUEUE_OVERFLOW, Metasync_Error_Logger::SEVERITY_WARNING, 'Action Scheduler queue overflow - too many pending actions', [ 'pending_count' => $pending_count, 'threshold' => 1000, 'queue_system' => 'Action Scheduler', 'operation' => 'queue_processing' ] ); } // Set transient to throttle future checks (1 hour) - only if not forced if (!$force_check) { $transient_key = 'metasync_queue_overflow_check'; set_transient($transient_key, time(), HOUR_IN_SECONDS); } return $pending_count; } catch (Exception $e) { // Fail silently to prevent breaking queue processing // error_log('MetaSync: Failed to check queue overflow: ' . $e->getMessage()); return false; } } // Hook into Action Scheduler before processing queue add_action('action_scheduler_before_process_queue', 'metasync_check_action_scheduler_queue_overflow', 10, 0); // Also check on shutdown (for immediate detection, throttled) add_action('shutdown', function() { // Only check in admin or if triggered manually if (is_admin() || isset($_GET['metasync_check_queue'])) { metasync_check_action_scheduler_queue_overflow(); } }, 999);