# metasync/2.6.22/includes/class-metasync-error-logger.php

Search Atlas SEO – OTTO AI SEO Automation for WordPress, version 2.6.22. 449 lines.

- Page: https://pluginprobe.com/plugins/metasync/2.6.22/code/includes/class-metasync-error-logger.php
- Raw: https://pluginprobe.com/plugins/metasync/2.6.22/raw/includes/class-metasync-error-logger.php
- Modified: 2026-08-13T00:12:54+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/metasync/2.6.22/code/includes/class-metasync-error-logger.php#L10-L20`.

```php
<?php
/**
 * Enhanced Error Logger with Categories and Hooks
 *
 * Implements structured error categorization with business logic categories
 * for better integration with external monitoring tools.
 *
 * @package    Metasync
 * @subpackage Metasync/includes
 * @since      1.0.0
 * @author     Engineering Team <support@searchatlas.com>
 */

// 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, "<?php\n// Silence is golden\n");
        }
        
        // Verify directory is writable
        if (!is_writable($log_directory)) {
            @chmod($log_directory, 0755);
            if (!is_writable($log_directory)) {
                error_log('Metasync_Error_Logger: Directory not writable: ' . $log_directory);
                return false;
            }
        }
        
        // Get log file path
        $log_file = wp_normalize_path($log_directory . '/' . self::LOG_FILE_NAME);
        
        // Append to log file with file locking
        $result = @file_put_contents($log_file, $log_line, FILE_APPEND | LOCK_EX);
        
        if ($result === false) {
            error_log('Metasync_Error_Logger: Failed to write to log file: ' . $log_file);
            return false;
        }
        
        // Set file permissions if file was just created
        if (!file_exists($log_file) || filesize($log_file) === strlen($log_line)) {
            @chmod($log_file, 0644);
        }
        
        return true;
    }
    
    /**
     * Update error summary in wp_options
     * 
     * Tracks last 100 unique errors with counts.
     * Unique key is based on category + first 50 chars of message.
     * 
     * @param string $category Error category
     * @param string $code Error code
     * @param string $message Error message
     * @param array $context Error context
     */
    private static function update_error_summary($category, $code, $message, $context) {
        // Get existing summary
        $summary = get_option(self::ERROR_SUMMARY_OPTION, []);
        
        if (!is_array($summary)) {
            $summary = [];
        }
        
        // Create unique key from category + first 50 chars of message
        $message_preview = substr($message, 0, 50);
        $key = $category . '|' . $message_preview;
        
        // Initialize entry if it doesn't exist
        if (!isset($summary[$key])) {
            $summary[$key] = [
                'category' => $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);

```
