# 404-solution/4.2.0/includes/Logging.php

404 Solution, version 4.2.0. 858 lines.

- Page: https://pluginprobe.com/plugins/404-solution/4.2.0/code/includes/Logging.php
- Raw: https://pluginprobe.com/plugins/404-solution/4.2.0/raw/includes/Logging.php
- Modified: 2026-05-24T08:07:28+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/404-solution/4.2.0/code/includes/Logging.php#L10-L20`.

```php
<?php


if (!defined('ABSPATH')) {
    exit;
}

/* Static functions that can be used from anywhere.  */

class ABJ_404_Solution_Logging {

    /** If an error happens then we will also output these.
     * @var array<int, string>
     */
    private static $storedDebugMessages = array();

    /** Used to store the last line sent from the debug file. */
    const LAST_SENT_LINE = 'last_sent_line';
    
    /** Used to store the the debug filename. */
    const DEBUG_FILE_KEY = 'debug_file_key';
    
    /** @var self|null */
    private static $instance = null;

    /** @var int Latest error-log line emailed during this PHP request. */
    private static $lastSentErrorLineThisRequest = 0;
    /** @var string Latest error signature emailed during this PHP request. */
    private static $lastSentErrorSignatureThisRequest = '';
    /** @var string Debug file path associated with the request-local dedupe state. */
    private static $lastSentDebugFilePathThisRequest = '';

    /**
     * Factory for the DI container.
     *
     * This avoids recursion when the container's 'logging' service is defined in terms of getInstance().
     *
     * @return ABJ_404_Solution_Logging
     */
    public static function createForContainer() {
        // Create a fresh instance without consulting the container.
        $logger = new ABJ_404_Solution_Logging();

        // Flush any pending errors captured before the logger existed.
        if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) {
            foreach ($GLOBALS['abj404_pending_errors'] as $message) {
                $logger->errorMessage($message);
            }
            unset($GLOBALS['abj404_pending_errors']); // Clear after flushing
        }

        // Also sync singleton for legacy callers.
        self::$instance = $logger;

        return $logger;
    }

    /** @return self */
    public static function getInstance() {
        if (self::$instance !== null) {
            return self::$instance;
        }

        // If the DI container is initialized, prefer it.
        if (class_exists('ABJ_404_Solution_ServiceContainer')) {
            $service = ABJ_404_Solution_ServiceContainer::safeGet('logging');
            if ($service instanceof ABJ_404_Solution_Logging) {
                self::$instance = $service;
                return self::$instance;
            }
        }

        self::$instance = new ABJ_404_Solution_Logging();

        // log any errors that were stored before the logger existed.
        if (isset($GLOBALS['abj404_pending_errors']) && is_array($GLOBALS['abj404_pending_errors'])) {
            foreach ($GLOBALS['abj404_pending_errors'] as $message) {
                self::$instance->errorMessage($message);
            }
            unset($GLOBALS['abj404_pending_errors']); // Clear after flushing
        }

        return self::$instance;
    }
    
    private function __construct() {
    }
    
    /** @return boolean true if debug mode is on. false otherwise. */
    function isDebug() {
        $abj404logic = abj_service('plugin_logic');
        $options = $abj404logic->getOptions(true);

        return (array_key_exists('debug_mode', $options) && $options['debug_mode'] == true);
    }
    
    /** for the current timezone. 
     * @return string */
    function getTimestamp() {
        $date = null;
        $timezoneStringRaw = get_option('timezone_string');
        $timezoneString = is_string($timezoneStringRaw) ? $timezoneStringRaw : '';

        if (!empty($timezoneString)) {
            $date = new DateTime("now", new DateTimeZone($timezoneString));
        } else {
            $gmtOffsetRaw = get_option('gmt_offset');
            // WordPress's gmt_offset is hours and may be fractional
            // (e.g. 5.5 India, 5.75 Nepal, -3.5 Newfoundland).
            $gmtOffsetHours = is_scalar($gmtOffsetRaw) ? (float)$gmtOffsetRaw : 0.0;
            $totalMinutes = (int) round($gmtOffsetHours * 60);
            $sign = $totalMinutes < 0 ? '-' : '+';
            $absMinutes = abs($totalMinutes);
            $tzString = sprintf('%s%02d:%02d', $sign, intdiv($absMinutes, 60), $absMinutes % 60);

            try {
                $date = new DateTime("now", new DateTimeZone($tzString));
            } catch (Exception $e) {
                // Use error_log (not $this->warn) because this method is part
                // of the logging path; calling warn here would risk recursion
                // if the timezone failure also breaks warn's own DateTime use.
                @error_log('404 Solution: timezone constructor failed (' . $e->getMessage() . '); using server default');
                $date = new DateTime();
            }
        }
        
        return $date->format('Y-m-d H:i:s T');
    }
    
    /** Send a message to the log file if debug mode is on.
     * This goes to a file and is used by every other class so it goes here.
     * @param string $message
     * @param \Exception|null $e If present then a stack trace is included.
     * @return void
     */
    function debugMessage(string $message, $e = null): void {
    	$stacktrace = "";
    	if ($e != null) {
    		$stacktrace = ", Stacktrace: " . $e->getTraceAsString();
    	}
    	
        $timestamp = $this->getTimestamp() . ' (DEBUG): ';
        if ($this->isDebug()) {
        	$this->writeLineToDebugFile($timestamp . $message . $stacktrace);
            
        } else {
        	array_push(self::$storedDebugMessages, $timestamp . $message . $stacktrace);
        }
    }

    /** Send a message to the log.
     * This goes to a file and is used by every other class so it goes here.
     * @param string $message
     * @return void
     */
    function infoMessage(string $message): void {
    	$timestamp = $this->getTimestamp() . ' (INFO): ';
    	$this->writeLineToDebugFile($timestamp . $message);
    }
    
    /** Send a message to the log.
     * This goes to a file and is used by every other class so it goes here.
     * @param string $message
     * @return void
     */
    function warn(string $message): void {
        $timestamp = $this->getTimestamp() . ' (WARN): ';
        $this->writeLineToDebugFile($timestamp . $message);
    }

    /** Always send a message to the error_log.
     * This goes to a file and is used by every other class so it goes here.
     * @param string $message
     * @param \Exception|null $e
     * @return void
     */
    function errorMessage(string $message, $e = null): void {
        if ($e == null) {
            $e = new Exception;
        }
        $stacktrace = $e->getTraceAsString();
        
        $savedDebugMessages = implode("\n", self::$storedDebugMessages);
        self::$storedDebugMessages = array();
        
        $timestamp = $this->getTimestamp() . ' (ERROR): ';
        $referrer = '';
        if (array_key_exists('HTTP_REFERER', $_SERVER) && !empty($_SERVER['HTTP_REFERER'])) {
            $referrer = $_SERVER['HTTP_REFERER'];
        }
        $requestedURL = '';
        if (array_key_exists('REQUEST_URI', $_SERVER) && !empty($_SERVER['REQUEST_URI'])) {
            $requestedURL = $_SERVER['REQUEST_URI'];
        }
        $this->writeLineToDebugFile($timestamp . $message . ", PHP version: " . PHP_VERSION . 
                ", WP ver: " . get_bloginfo('version') . ", Plugin ver: " . ABJ404_VERSION . 
                ", Referrer: " . $referrer . ", Requested URL: " . $requestedURL . 
                ", \nStored debug messages: \n" . $savedDebugMessages . ", \nTrace: " . $stacktrace);
    }
    
    /** Log the user capabilities.
     * @param string $msg
     * @return void
     */
    function logUserCapabilities(string $msg): void {
    	$f = abj_service('functions');
    	$abj404logic = abj_service('plugin_logic');
    	$user = wp_get_current_user();
        $usercaps = $f->str_replace(',"', ', "', wp_kses_post((string)json_encode($user->get_role_caps())));
        
        $userIsPluginAdminStr = "false";
        if ($abj404logic->userIsPluginAdmin()) {
        	$userIsPluginAdminStr = "true";
        }
        
        $this->debugMessage("User caps msg: " . esc_html($msg == '' ? '(none)' : $msg) . ", is_admin(): " . is_admin() .
        		", current_user_can('manage_options'): " . current_user_can('manage_options') .
        		", current_user_can('administrator'): " . current_user_can('administrator') .
        		", userIsPluginAdmin(): " . $userIsPluginAdminStr .
        		", user_login: " . esc_html($user->user_login ?? '(none)') .
                ", user caps: " . wp_kses_post((string)json_encode($user->caps)) . ", get_role_caps: " .
                $usercaps . ", WP ver: " . get_bloginfo('version') . ", mbstring: " .
                (extension_loaded('mbstring') ? 'true' : 'false'));
    }

    /** Write the line to the debug file.
     *
     * Sanitizes PII at write-time for GDPR compliance (defense in depth).
     * Fix for disk space error (reported by 1 user - 2% of errors)
     * Handles file write failures gracefully to prevent error loops when disk is full.
     * Uses error suppression and returns status instead of throwing exceptions.
     *
     * @param string $line
     * @return bool True on success, false on failure
     */
    function writeLineToDebugFile($line) {
        // Sanitize PII at write-time (GDPR compliance)
        // This protects all 372 logging calls across the codebase
        $sanitizedLine = $this->sanitizeLogLine($line);

        // Suppress errors to prevent fatal error when disk is full
        $result = @file_put_contents($this->getDebugFilePath(), $sanitizedLine . "\n", FILE_APPEND);

        if ($result === false) {
            // Disk full or permissions issue - log to error_log instead to avoid infinite loop
            // Don't use errorMessage() here as it would call this function again
            error_log('404 Solution: Unable to write to debug log (possibly disk full): ' .
                $this->getDebugFilePath());
            return false;
        }

        return true;
    }
    
    /** Email the log file to the plugin developer.
     *
     * Cron-context entry: builds a FeedbackTransport payload from the freshly-
     * scanned latest-error line plus dedup state, and dispatches via
     * FeedbackTransport::sendNow() (sync HTTP POST + email fallback). Returns
     * true iff any transport (HTTP or email) succeeded; the dedup pointer is
     * advanced before sending so a transport failure does not cause repeated
     * sends of the same error line on the next cron tick.
     *
     * @return bool
     */
    function emailErrorLogIfNecessary(): bool {
        $abj404dao = abj_service('data_access');
        $abj404logic = abj_service('plugin_logic');
        $options = $abj404logic->getOptions(true);

        if (!file_exists($this->getDebugFilePath())) {
            $this->debugMessage("No log file found so no errors were found.");
            return false;
        }

        // get the number of the last line with an error message.
        $latestErrorLineFound = $this->getLatestErrorLine();

        // if no error was found then we're done.
        if ($latestErrorLineFound['num'] == -1) {
            $this->debugMessage("No errors found in the log file.");
            return false;
        }

        // -------------------
        // get/check the last line that was emailed to the admin.
        $sentDateFile = $this->getDebugFilePathSentFile();
        $debugFilePath = $this->getDebugFilePath();

        $sentLine = -1;
        if (file_exists($sentDateFile)) {
            $sentLine = absint(
            	ABJ_404_Solution_Functions::readFileContents($sentDateFile, false));
            $this->debugMessage("Last sent line from file: " . $sentLine);
        }
        if ($sentLine < 1 && array_key_exists(self::LAST_SENT_LINE, $options)) {
        	$sentLine = is_scalar($options[self::LAST_SENT_LINE]) ? (int)$options[self::LAST_SENT_LINE] : -1;
       		$this->debugMessage("Last sent line from options: " . $sentLine);
        }

        // if we already sent the error line then don't send the log file again.
        if (self::$lastSentDebugFilePathThisRequest === $debugFilePath) {
            $sentLine = max($sentLine, self::$lastSentErrorLineThisRequest);
        }
        $latestSignature = (string)($latestErrorLineFound['line'] ?? '');
        if ($latestErrorLineFound['num'] <= $sentLine
            || (self::$lastSentDebugFilePathThisRequest === $debugFilePath
                && $latestSignature !== '' && $latestSignature === self::$lastSentErrorSignatureThisRequest)) {
            $this->debugMessage("The latest error line from the log file was already emailed. " . $latestErrorLineFound['num'] .
                    ' <= ' . $sentLine);
            return false;
        }

        // only email the error file if the latest version of the plugin is installed.
        if (!$abj404dao->shouldEmailErrorFile()) {
            return false;
        }

        // update the latest error line emailed to the developer.
        $options[self::LAST_SENT_LINE] = $latestErrorLineFound['num'];
        self::$lastSentErrorLineThisRequest = (int)$latestErrorLineFound['num'];
        self::$lastSentErrorSignatureThisRequest = $latestSignature;
        self::$lastSentDebugFilePathThisRequest = $debugFilePath;
        $abj404logic->updateOptions($options);
        file_put_contents($sentDateFile, $latestErrorLineFound['num']);
        $fileContents = file_get_contents($sentDateFile);
        if ($fileContents != $latestErrorLineFound['num']) {
        	$this->errorMessage("There was an issue writing to the file " . $sentDateFile);
        	return false;
        }

        $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('error', array(
            'error_signature' => (string)($latestErrorLineFound['line'] ?? ''),
            'previously_sent_line' => (int)$sentLine,
            'error_count_in_log' => (int)$latestErrorLineFound['total_error_count'],
        ));
        return ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'error');
    }

    /**
     * Roll a 1-in-N dice and send a full debug zip as a heartbeat if it hits.
     * Called during daily maintenance for opted-in sites when no error email was sent.
     *
     * Dispatches via FeedbackTransport::sendNow() (HTTP POST + email fallback)
     * with type='heartbeat' so the same payload shape is shared with the error
     * path.
     *
     * @param int $oneInN Probability denominator (default 200 = ~once per 6 months).
     * @return bool True if a heartbeat was sent.
     */
    function sendHeartbeatIfDueRandom(int $oneInN = 200): bool {
        if (!file_exists($this->getDebugFilePath())) {
            return false;
        }
        if (mt_rand(1, $oneInN) !== 1) {
            return false;
        }
        $this->debugMessage("Heartbeat dice roll hit (1-in-{$oneInN}). Sending heartbeat log.");
        $errorInfo = $this->getLatestErrorLine();

        $payload = ABJ_404_Solution_FeedbackTransport::buildPayload('heartbeat', array(
            'error_signature' => 'Heartbeat: no errors to report.',
            'previously_sent_line' => 0,
            'error_count_in_log' => (int)$errorInfo['total_error_count'],
        ));
        ABJ_404_Solution_FeedbackTransport::sendNow($payload, 'heartbeat');
        return true;
    }

    /**
     * Email-fallback for FeedbackTransport when the HTTP POST of an error or
     * heartbeat report fails. Builds an HTML email body purely from the
     * FeedbackTransport payload (single source of truth shared with the HTTP
     * path) and attaches a zip of the current debug log file(s).
     *
     * Public because FeedbackTransport::sendNow() invokes it via the service
     * container for type='error' and type='heartbeat'.
     *
     * @param array<string, mixed> $payload FeedbackTransport-built payload.
     * @return bool True if wp_mail() reported success, false otherwise.
     */
    function emailLogFileToDeveloper(array $payload): bool {
        $isHeartbeat = (isset($payload['report_type']) && $payload['report_type'] === 'heartbeat');
        $errorLineMessage = isset($payload['error_signature']) && is_scalar($payload['error_signature'])
            ? (string)$payload['error_signature'] : '';
        $totalErrorCount = isset($payload['error_count_in_log']) && is_scalar($payload['error_count_in_log'])
            ? (int)$payload['error_count_in_log'] : 0;
        $previouslySentLine = isset($payload['previously_sent_line']) && is_scalar($payload['previously_sent_line'])
            ? (int)$payload['previously_sent_line'] : 0;

        $this->debugMessage("Creating zip file of error log file. " .
        	"Previously sent error line: " . $previouslySentLine);
        $logFileZip = $this->getZipFilePath();
        if (file_exists($logFileZip)) {
            ABJ_404_Solution_Functions::safeUnlink($logFileZip);
        }
        $zip = new ZipArchive;
        if ($zip->open($logFileZip, ZipArchive::CREATE) === true) {
            if (file_exists($this->getDebugFilePath())) {
                $zip->addFile($this->getDebugFilePath(), basename($this->getDebugFilePath()));
            }
            if (file_exists($this->getDebugFilePathOld())) {
            	$zip->addFile($this->getDebugFilePathOld(), basename($this->getDebugFilePathOld()));
            }
            $zip->close();
        }

        $logTableSizeMB = round((int)($payload['log_table_size_bytes'] ?? 0) / (1024 * 1024), 2);
        $debugFileSizeMB = round((int)($payload['debug_file_size_bytes'] ?? 0) / (1024 * 1024), 2);

        $to = ABJ404_AUTHOR_EMAIL;
        $subject = ABJ404_PP . ($isHeartbeat ? ' heartbeat' : ' error') . ' log file. Plugin version: ' . ABJ404_VERSION;
        $extensions = isset($payload['extensions']) && is_array($payload['extensions']) ? $payload['extensions'] : array();
        $activePlugins = isset($payload['active_plugins']) && is_array($payload['active_plugins']) ? $payload['active_plugins'] : array();
        $isMultisite = !empty($payload['is_multisite']);

        $bodyLines = array();
        $bodyLines[] = $subject . ". Sent " . date('Y/m/d h:i:s T');
        $bodyLines[] = " ";
        $bodyLines[] = "Error: " . $errorLineMessage;
        $bodyLines[] = " ";
        $bodyLines[] = "PHP version: " . (string)($payload['php_version'] ?? PHP_VERSION);
        $bodyLines[] = "WordPress version: " . (string)($payload['wp_version'] ?? '');
        $bodyLines[] = "Plugin version: " . (string)($payload['plugin_version'] ?? ABJ404_VERSION);
        $bodyLines[] = "MySQL version: " . (string)($payload['db_version'] ?? '');
        $bodyLines[] = "Site URL: " . (string)($payload['site_url'] ?? '');
        $bodyLines[] = "Multisite: " . ($isMultisite ? 'yes' : 'no');
        if ($isMultisite && function_exists('is_plugin_active_for_network')) {
            $bodyLines[] = "Network activated: " . (is_plugin_active_for_network(plugin_basename(ABJ404_FILE)) ? 'yes' : 'no');
        }
        $bodyLines[] = "WP_MEMORY_LIMIT: " . (defined('WP_MEMORY_LIMIT') ? WP_MEMORY_LIMIT : '');
        $bodyLines[] = "Extensions: " . implode(", ", $extensions);
        $bodyLines[] = " ";
        $bodyLines[] = "--- WordPress Content Counts ---";
        $bodyLines[] = "Published posts: " . (string)($payload['published_posts_count'] ?? '0');
        $bodyLines[] = "Published pages: " . (string)($payload['published_pages_count'] ?? '0');
        $bodyLines[] = "Categories: " . (string)($payload['categories_count'] ?? '0');
        $bodyLines[] = "Tags: " . (string)($payload['tags_count'] ?? '0');
        $bodyLines[] = " ";
        $bodyLines[] = "--- 404 Solution Counts ---";
        $bodyLines[] = "Total redirects (active): " . (string)($payload['redirects_active_total'] ?? '0');
        $bodyLines[] = "  - Manual redirects: " . (string)($payload['redirects_manual_count'] ?? '0');
        $bodyLines[] = "  - Automatic redirects: " . (string)($payload['redirects_automatic_count'] ?? '0');
        $bodyLines[] = "  - Regex redirects: " . (string)($payload['redirects_regex_count'] ?? '0');
        $bodyLines[] = "  - Trashed redirects: " . (string)($payload['redirects_trashed_count'] ?? '0');
        $bodyLines[] = "Captured 404s (active): " . (string)($payload['captured_404s_active_total'] ?? '0');
        $bodyLines[] = "  - Captured (new): " . (string)($payload['captured_404s_new_count'] ?? '0');
        $bodyLines[] = "  - Ignored: " . (string)($payload['captured_404s_ignored_count'] ?? '0');
        $bodyLines[] = "  - Later: " . (string)($payload['captured_404s_later_count'] ?? '0');
        $bodyLines[] = "  - Trashed: " . (string)($payload['captured_404s_trashed_count'] ?? '0');
        $bodyLines[] = "Log entries in database: " . (string)($payload['log_entries_count'] ?? '0');
        $bodyLines[] = "Log table size: " . $logTableSizeMB . " MB";
        $bodyLines[] = " ";
        $bodyLines[] = "Total error count in log file: " . $totalErrorCount;
        $bodyLines[] = "Debug file name: " . $this->getDebugFilename();
        $bodyLines[] = "Debug file size: " . $debugFileSizeMB . " MB";
        $bodyLines[] = "Active plugins: <pre>" .
          json_encode($activePlugins, JSON_PRETTY_PRINT) . "</pre>";

        $body = implode("<BR/>\n", $bodyLines);

        $headers = array('Content-Type: text/html; charset=UTF-8');
        $headers[] = 'From: ' . get_option('admin_email');

        $attachments = array();
        if (file_exists($logFileZip)) {
            $attachments[] = $logFileZip;
        }

        $this->debugMessage("Sending error log zip file as attachment.");
        $result = wp_mail($to, $subject, $body, $headers, $attachments);

        if (file_exists($logFileZip)) {
            ABJ_404_Solution_Functions::safeUnlink($logFileZip);
        }
        $this->debugMessage("Mail sent. Log zip file deleted.");
        return (bool)$result;
    }
    
    /**
     * @return array{num: int, line: string|null, total_error_count: int}
     */
    function getLatestErrorLine(): array {
        $f = abj_service('functions');
        $latestErrorLineFound = array();
        $latestErrorLineFound['num'] = -1;
        $latestErrorLineFound['line'] = null;
        $latestErrorLineFound['total_error_count'] = 0;
        $linesRead = 0;
        $handle = null;
        $collectingErrorLines = false;
        try {
            $debugPath = $this->getDebugFilePath();
            // Check existence before fopen so PHP does not emit a warning on a
            // missing debug file. The file is absent on fresh installs and in
            // most test fixtures. Return the empty initialized array (no error
            // line) in that case rather than letting fopen warn and return
            // false. failOnWarning=true in phpunit.xml means an unguarded
            // warning here trips the whole preflight gate.
            if (!is_string($debugPath) || $debugPath === '' || !file_exists($debugPath)) {
                return $latestErrorLineFound;
            }
            if ($handle = fopen($debugPath, "r")) {
                // read the file one line at a time.
                while (($line = fgets($handle)) !== false) {
                    $linesRead++;
                    // if the line has an error then save the line number.
                    $hasError = stripos($line, '(ERROR)');
                    $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user');
                    if ($hasError !== false && $isDeleteError === false) {
                    	$latestErrorLineFound['num'] = $linesRead;
                        $latestErrorLineFound['line'] = $line;
                        $latestErrorLineFound['total_error_count'] += 1;
                        $collectingErrorLines = true;
                        
                    } else if ($collectingErrorLines && 
                    	!$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) {
                        // if we're collecting error lines and we haven't found the 
                        // beginning of a new debug message then continue collecting lines.
                        $latestErrorLineFound['line'] .= "<BR/>\n" . $line;
                        
                    } else {
                    	// this must be the beginning of a new debug message so we'll stop
                    	// collecting error lines.
                    	$collectingErrorLines = false;
                   	}
                }
            } else {
                $this->errorMessage("Error reading log file (1).");
            }
            
        } catch (Exception $e) {
            $this->errorMessage("Error reading log file. (2)", $e);
        }
            
        if ($handle != null) {
            fclose($handle);
        }
        
        return $latestErrorLineFound;
    }
    
    /**
     * Get sanitized log excerpt for support emails.
     * Collects last 15 ERROR/WARN entries (already sanitized at write-time)
     * plus the last 20 lines for recent context (admin actions, AJAX calls).
     * If no errors/warnings found, includes only the last 20 lines.
     *
     * @return string Sanitized log excerpt or message if no errors found
     */
    function getSanitizedLogExcerptForSupport() {
        $f = abj_service('functions');
        $errorEntries = array();
        $recentLines = array();
        $maxEntries = 15;
        $maxRecentLines = 20;
        $totalLines = 0;
        $handle = null;

        try {
            $debugFilePath = $this->getDebugFilePath();

            if (!file_exists($debugFilePath)) {
                return "No log file available";
            }

            if ($handle = fopen($debugFilePath, "r")) {
                $currentEntry = array();
                $collectingEntry = false;

                // Read file line by line
                while (($line = fgets($handle)) !== false) {
                    $totalLines++;

                    // Keep a sliding window of recent lines (for fallback if no errors)
                    $recentLines[] = $line;
                    if (count($recentLines) > $maxRecentLines) {
                        array_shift($recentLines);
                    }

                    // Check if this is an ERROR or WARN line
                    $hasError = stripos($line, '(ERROR)') !== false;
                    $hasWarn = stripos($line, '(WARN)') !== false;
                    $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user') !== false;

                    // Start collecting if we find ERROR or WARN (but skip known benign errors)
                    if (($hasError || $hasWarn) && !$isDeleteError) {
                        // If we were collecting a previous entry, save it
                        if ($collectingEntry && !empty($currentEntry)) {
                            $errorEntries[] = $currentEntry;
                            // Keep only last N entries (sliding window)
                            if (count($errorEntries) > $maxEntries) {
                                array_shift($errorEntries);
                            }
                        }

                        // Start new entry (no sanitization needed - already done at write-time)
                        $currentEntry = array($line);
                        $collectingEntry = true;

                    } else if ($collectingEntry &&
                               !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line)) {
                        // Continue collecting multiline error (no sanitization needed - already done at write-time)
                        $currentEntry[] = $line;

                    } else {
                        // New log entry started, save previous if exists
                        if ($collectingEntry && !empty($currentEntry)) {
                            $errorEntries[] = $currentEntry;
                            if (count($errorEntries) > $maxEntries) {
                                array_shift($errorEntries);
                            }
                        }
                        $collectingEntry = false;
                        $currentEntry = array();
                    }
                }

                // Save last entry if we were still collecting
                if ($collectingEntry && !empty($currentEntry)) {
                    $errorEntries[] = $currentEntry;
                    if (count($errorEntries) > $maxEntries) {
                        array_shift($errorEntries);
                    }
                }

                fclose($handle);

            } else {
                return "Log file not readable";
            }

        } catch (Exception $e) { // allow-silent-catch: log excerpt for support bundle; "Error reading log file" is itself diagnostic and gets embedded in the bundle output
            return "Error reading log file";
        }

        // Format output
        if (empty($errorEntries)) {
            // No errors/warnings found - include last N lines for context
            if (empty($recentLines)) {
                return "Log file is empty";
            }
            $output = "No ERROR/WARN entries found. Last " . count($recentLines) . " log lines:\n\n";
            $output .= implode("", $recentLines);
            return trim($output);
        }

        $output = "Last " . count($errorEntries) . " ERROR/WARN entries:\n\n";
        foreach ($errorEntries as $entry) {
            $output .= implode("\n", $entry) . "\n\n";
        }

        if (!empty($recentLines)) {
            $output .= "Recent context (last " . count($recentLines) . " lines):\n\n";
            $output .= implode("", $recentLines);
        }

        return trim($output);
    }

    /**
     * Sanitize a single log line for privacy (GDPR compliance).
     * Delegates to PiiRedactor for all pattern matching and masking.
     *
     * @param string $line Log line to sanitize
     * @return string Sanitized line with PII masked adaptively
     */
    public function sanitizeLogLine($line) {
        try {
            /** @var ABJ_404_Solution_PiiRedactor $redactor */
            $redactor = abj_service('pii_redactor');
        } catch (\Exception $e) {
            // allow-silent-catch: early boot before container is initialized; fall through to raw line
            return $line;
        }
        return $redactor->redact($line);
    }

    /** Return the path to the debug file.
     * @return string
     */
    function getDebugFilePath() {
        $debugFileName = $this->getDebugFilename();
        return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), $debugFileName);
    }
    
    /** @return string */
    function getDebugFilename(): string {
        // The is_object() / method_exists() guards below catch the static
        // unreachability cases (container miss, factory returned null), but
        // they cannot catch a Throwable raised from inside the resolved call
        // — getOptions() may surface a DB read failure, uniqidReal() may
        // raise on a corrupt random source, updateOptions() may fail to
        // persist. writeLineToDebugFile() promises non-throwing; any escape
        // from this method violates that contract. Absorb every Throwable
        // and return the deterministic fallback name so logging stays
        // available even when upstream services are degraded.
        try {
            // get the UUID here.
            $abj404logic = abj_service('plugin_logic');
            // abj_service returns null when the container is uninitialised
            // or the factory threw — common during very-early boot, the
            // test harness, and self-healing recovery from broken installs.
            if (!is_object($abj404logic) || !method_exists($abj404logic, 'getOptions')) {
                return 'abj404_debug.txt';
            }
            $options = $abj404logic->getOptions(true);
            $debugFileKey = null;
            if (is_array($options) && array_key_exists(self::DEBUG_FILE_KEY, $options)) {
                $debugFileKey = is_string($options[self::DEBUG_FILE_KEY]) ? $options[self::DEBUG_FILE_KEY] : null;
            }
            // if the key doesn't exist then create it.
            if ($debugFileKey === null || trim($debugFileKey) === '') {
                // delete any lingering debug files.
                $this->deleteDebugFile();

                // create a probably unique UUID and store it to the database.
                $syncUtils = abj_service('sync_utils');
                if (!is_object($syncUtils) || !method_exists($syncUtils, 'uniqidReal')) {
                    return 'abj404_debug.txt';
                }
                $debugFileKey = $syncUtils->uniqidReal();
                $options[self::DEBUG_FILE_KEY] = $debugFileKey;
                if (method_exists($abj404logic, 'updateOptions')) {
                    $abj404logic->updateOptions($options);
                }
            }

            return 'abj404_debug_' . $debugFileKey . '.txt';
        } catch (\Throwable $e) { // allow-silent-catch: debug filename derivation; fallback to default name still produces a valid path for log writes
            return 'abj404_debug.txt';
        }
    }
    
    /** @return string */
    function getDebugFilePathOld(): string {
        return $this->getDebugFilePath() . "_old.txt";
    }
    
    /** Return the path to the file that stores the latest error line in the log file.
     * @return string
     */
    function getDebugFilePathSentFile() {
    	return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug_sent_line.txt');
    }
    
    /** Return the path to the zip file for sending the debug file. 
     * @return string
     */
    function getZipFilePath() {
    	return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug.zip');
    }
    
    /** This is for legacy support. On new installations it creates a directory and returns
     * a file path. On old installations it moved the old file to the new location. 
     * If the directory can't be created then it falls back to the old location.
     * @param string $directory
     * @param string $filename
     * @return string
     */
    function getFilePathAndMoveOldFile($directory, $filename) {
    	$f = abj_service('functions');
        // create the directory and move the file
        if (!$f->createDirectoryWithErrorMessages($directory)) {
            return ABJ404_PATH . $filename;
        }
        
        if (file_exists(ABJ404_PATH . $filename)) {
            // move the file to the new location
            rename(ABJ404_PATH . $filename, $directory . $filename);
        }
        
        return $directory . $filename;
    }
    
    /** @return void */
    function limitDebugFileSize(): void {
        // delete the sent_line file since it's now incorrect.
        if (file_exists($this->getDebugFilePathSentFile())) {
            ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile());
        }

        // update the last sent error line since the debug file will be deleted.
        $this->removeLastSentErrorLineFromDatabase();
        
        // delete _old log file
        ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathOld());
        // rename current log file to _old
        rename($this->getDebugFilePath(), $this->getDebugFilePathOld());
    }
    
    /** @return void */
    function removeLastSentErrorLineFromDatabase(): void {
    	// update the last sent error line since the debug file will be deleted.
    	$abj404logic = abj_service('plugin_logic');
    	$options = $abj404logic->getOptions(true);
    	$options[self::LAST_SENT_LINE] = 0;
    	$abj404logic->updateOptions($options);
    }
    
    /** Deletes all files named abj404_debug_*.txt
     * @return boolean true if the file was deleted.
     */
    function deleteDebugFile() {
        $abj404logic = abj_service('plugin_logic');
        $allIsWell = true;
        
        // since the debug file is being deleted we reset the last error line that was sent.
        if (file_exists($this->getDebugFilePathSentFile())) {
            ABJ_404_Solution_Functions::safeUnlink($this->getDebugFilePathSentFile());
        }
        // update the last sent error line since the debug file will be deleted.
        $this->removeLastSentErrorLineFromDatabase();
        
        // delete the debug file(s).
        // list any files in the directory and delete any files named debug_*.txt
        $uploadDir = abj404_getUploadsDir();
        // Check if the directory exists
        if (is_dir($uploadDir)) {
            // Get all files matching the pattern abj404_debug_*.txt
            $files = glob($uploadDir . '/abj404_debug_*.txt');
            if (!is_array($files)) { $files = array(); }
            foreach ($files as $file) { // Loop through the files and delete them
                if (is_file($file)) {
                    // Delete the file
                    if (!ABJ_404_Solution_Functions::safeUnlink($file)) {
                        $allIsWell = false;
                    }
                }
            }
        }
        
        // reset the UUID since we deleted the log file.
        $options = $abj404logic->getOptions(true);
        $options[self::DEBUG_FILE_KEY] = null;
        $abj404logic->updateOptions($options);
        
        return $allIsWell;
    }
    
    /** 
     * @return int file size in bytes
     */
    function getDebugFileSize() {
        $file1Size = 0;
        $file2Size = 0;
        if (file_exists($this->getDebugFilePath())) {
            $file1Size = filesize($this->getDebugFilePath());
        }
        if (file_exists($this->getDebugFilePathOld())) {
            $file2Size = filesize($this->getDebugFilePathOld());
        }
        
        return $file1Size + $file2Size;
    }
    
}

```
