refactor(database): enhance error logging and various fixes (#1978)
Some checks are pending
Continuous Integration / Nightly builds 📦 (push) Waiting to run
Continuous Integration / 🎉 Deploy (push) Waiting to run
PHPMD / Run PHPMD scanning (push) Waiting to run

* refactor(database): enhance error logging and handling in Database and DatabaseDebugger classes

- Updated error handling in Database class to provide more meaningful error messages, including detailed PDO error information.
- Enhanced log_error method in DatabaseDebugger to accept exceptions, allowing for more reliable error logging with comprehensive context.
- Improved user-facing error messages while maintaining detailed logging for administrators and developers.
- Added checks for connection status and query context in error logs to aid in debugging.

* refactor(database): streamline error handling and improve code clarity in Database class

- Removed unused imports and global variable references to enhance code readability.
- Simplified error handling by refining conditions for empty input arrays.
- Defined variables early in the error logging method to ensure consistent access throughout.
- Improved comments for better understanding of the error handling logic.

* fix(database): refine development mode condition in Database class

- Removed unnecessary function check from the development mode condition to streamline the logic.

* fix(redirect): add no-cache headers to prevent browser caching of redirects

- Implemented `send_no_cache_headers()` in the redirect function to ensure that browsers do not cache redirect responses, adhering to best practices for HTTP redirects.

* refactor(database): enhance error handling and logging in Database and Whoops classes

- Improved error handling in the Database class to log detailed information for duplicate column errors and automatically retry problematic queries using PDO.
- Introduced a new DatabaseErrorHandler to enhance Whoops error reporting with comprehensive database context and recent query history.
- Updated EnhancedPrettyPageHandler to include dynamic database information in error outputs, improving debugging capabilities.
- Added legacy query tracking in DatabaseDebugger to identify and log queries needing compatibility fixes, ensuring better maintenance of legacy code.

* refactor(posting): rollback changes in SQL queries up to legacy format
This commit is contained in:
Yury Pikhtarev 2025-06-20 18:52:30 +04:00 committed by GitHub
commit 7aed6bc7d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1150 additions and 45 deletions

View file

@ -26,9 +26,9 @@ $posts_without_attach = $topics_without_attach = [];
DB()->query("
CREATE TEMPORARY TABLE $tmp_attach_tbl (
physical_filename VARCHAR(255) NOT NULL default '',
physical_filename VARCHAR(255) NOT NULL default '' COLLATE utf8mb4_unicode_ci,
KEY physical_filename (physical_filename(20))
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_unicode_ci
");
DB()->add_shutdown_query("DROP TEMPORARY TABLE IF EXISTS $tmp_attach_tbl");

View file

@ -1437,6 +1437,9 @@ function redirect($url)
$redirect_url = $server_protocol . $server_name . $server_port . $script_name . preg_replace('#^\/?(.*?)\/?$#', '/\1', $url);
// Send no-cache headers to prevent browsers from caching redirects
send_no_cache_headers();
// Behave as per HTTP/1.1 spec for others
header('Location: ' . $redirect_url, response_code: 301);
exit;

View file

@ -10,16 +10,10 @@
namespace TorrentPier\Database;
use Nette\Database\Connection;
use Nette\Database\Conventions\DiscoveredConventions;
use Nette\Database\Explorer;
use Nette\Database\ResultSet;
use Nette\Database\Row;
use Nette\Database\Table\Selection;
use Nette\Database\Structure;
use Nette\Database\Conventions\DiscoveredConventions;
use TorrentPier\Database\DebugSelection;
use TorrentPier\Database\DatabaseDebugger;
use TorrentPier\Dev;
use TorrentPier\Legacy\SqlDb;
/**
* Modern Database class using Nette Database with backward compatibility
@ -55,6 +49,7 @@ class Database
public float $sql_timetotal = 0;
public float $cur_query_time = 0;
public ?string $cur_query = null;
public ?string $last_query = null; // Store last executed query for error reporting
public array $shutdown = [];
public array $DBS = [];
@ -64,8 +59,6 @@ class Database
*/
private function __construct(array $cfg_values, string $server_name = 'db')
{
global $DBS;
$this->cfg = array_combine($this->cfg_keys, $cfg_values);
$this->db_server = $server_name;
@ -188,12 +181,13 @@ class Database
// Initialize affected rows to 0 (most queries don't affect rows)
$this->last_affected_rows = 0;
} catch (\Exception $e) {
$this->debugger->log_error();
$this->debugger->log_error($e);
$this->result = null;
$this->last_affected_rows = 0;
}
$this->debugger->debug('stop');
$this->last_query = $this->cur_query; // Preserve for error reporting
$this->cur_query = null;
if ($this->inited) {
@ -247,20 +241,69 @@ class Database
return false;
}
$row = $result->fetch();
if (!$row) {
return false;
try {
$row = $result->fetch();
if (!$row) {
return false;
}
// Convert Row to array for backward compatibility
// Nette Database Row extends ArrayHash, so we can cast it to array
$rowArray = (array)$row;
if ($field_name) {
return $rowArray[$field_name] ?? false;
}
return $rowArray;
} catch (\Exception $e) {
// Check if this is a duplicate column error
if (str_contains($e->getMessage(), 'Found duplicate columns')) {
// Log this as a problematic query that needs fixing
$this->debugger->logLegacyQuery($this->last_query ?? $this->cur_query ?? 'Unknown query', $e->getMessage());
// Automatically retry by re-executing the query with direct PDO
// This bypasses Nette's duplicate column check completely
try {
// Extract the clean SQL query
$cleanQuery = $this->last_query ?? $this->cur_query ?? '';
// Remove Nette's debug comment
$cleanQuery = preg_replace('#^(\s*)(/\*)(.*)(\*/)(\s*)#', '', $cleanQuery);
if (!$cleanQuery) {
throw new \RuntimeException('Could not extract clean query for PDO retry');
}
// Execute directly with PDO to bypass Nette's column checking
$stmt = $this->connection->getPdo()->prepare($cleanQuery);
$stmt->execute();
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
// PDO::FETCH_ASSOC automatically handles duplicate columns by keeping the last occurrence
// which matches MySQL's behavior for SELECT t.*, f.* queries
if (!$row) {
return false;
}
if ($field_name) {
return $row[$field_name] ?? false;
}
return $row;
} catch (\Exception $retryException) {
// If PDO retry also fails, log and re-throw
$this->debugger->log_error($retryException);
throw $retryException;
}
}
// Log the error including the query that caused it
$this->debugger->log_error($e);
// Re-throw the exception so it can be handled by Whoops
throw $e;
}
// Convert Row to array for backward compatibility
// Nette Database Row extends ArrayHash, so we can cast it to array
$rowArray = (array)$row;
if ($field_name) {
return $rowArray[$field_name] ?? false;
}
return $rowArray;
}
/**
@ -280,7 +323,22 @@ class Database
$this->trigger_error();
}
return $this->sql_fetchrow($result, $field_name);
try {
return $this->sql_fetchrow($result, $field_name);
} catch (\Exception $e) {
// Enhance the exception with query information
$enhancedException = new \RuntimeException(
"Database error during fetch_row: " . $e->getMessage() .
"\nProblematic Query: " . ($this->cur_query ?: $this->last_query ?: 'Unknown'),
$e->getCode(),
$e
);
// Log the enhanced error
$this->debugger->log_error($enhancedException);
throw $enhancedException;
}
}
/**
@ -293,11 +351,48 @@ class Database
}
$rowset = [];
while ($row = $result->fetch()) {
// Convert Row to array for backward compatibility
// Nette Database Row extends ArrayHash, so we can cast it to array
$rowArray = (array)$row;
$rowset[] = $field_name ? ($rowArray[$field_name] ?? null) : $rowArray;
try {
while ($row = $result->fetch()) {
// Convert Row to array for backward compatibility
// Nette Database Row extends ArrayHash, so we can cast it to array
$rowArray = (array)$row;
$rowset[] = $field_name ? ($rowArray[$field_name] ?? null) : $rowArray;
}
} catch (\Exception $e) {
// Check if this is a duplicate column error
if (str_contains($e->getMessage(), 'Found duplicate columns')) {
// Log this as a problematic query that needs fixing
$this->debugger->logLegacyQuery($this->last_query ?? $this->cur_query ?? 'Unknown query', $e->getMessage());
// Automatically retry by re-executing the query with direct PDO
try {
// Extract the clean SQL query
$cleanQuery = $this->last_query ?? $this->cur_query ?? '';
// Remove Nette's debug comment
$cleanQuery = preg_replace('#^(\s*)(/\*)(.*)(\*/)(\s*)#', '', $cleanQuery);
if (!$cleanQuery) {
throw new \RuntimeException('Could not extract clean query for PDO retry');
}
// Execute directly with PDO to bypass Nette's column checking
$stmt = $this->connection->getPdo()->prepare($cleanQuery);
$stmt->execute();
while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) {
$rowset[] = $field_name ? ($row[$field_name] ?? null) : $row;
}
} catch (\Exception $retryException) {
// If PDO retry also fails, log and re-throw
$this->debugger->log_error($retryException);
throw $retryException;
}
} else {
// For other exceptions, just re-throw
$this->debugger->log_error($e);
throw $e;
}
}
return $rowset;
@ -423,8 +518,8 @@ class Database
$dont_escape = $data_already_escaped;
$check_type = $check_data_type_in_escape;
if (empty($input_ary) || !is_array($input_ary)) {
$this->trigger_error(__FUNCTION__ . ' - wrong params: $input_ary');
if (empty($input_ary)) {
$this->trigger_error(__FUNCTION__ . ' - wrong params: $input_ary is empty');
}
if ($query_type == 'INSERT') {
@ -549,9 +644,28 @@ class Database
if ($this->connection) {
try {
$pdo = $this->connection->getPdo();
$errorCode = $pdo->errorCode();
$errorInfo = $pdo->errorInfo();
// Filter out "no error" states - PDO returns '00000' when there's no error
if (!$errorCode || $errorCode === '00000') {
return ['code' => '', 'message' => ''];
}
// Build meaningful error message from errorInfo array
// errorInfo format: [SQLSTATE, driver-specific error code, driver-specific error message]
$message = '';
if (isset($errorInfo[2]) && $errorInfo[2]) {
$message = $errorInfo[2]; // Driver-specific error message is most informative
} elseif (isset($errorInfo[1]) && $errorInfo[1]) {
$message = "Error code: " . $errorInfo[1];
} else {
$message = "SQLSTATE: " . $errorCode;
}
return [
'code' => $pdo->errorCode(),
'message' => implode(': ', $pdo->errorInfo())
'code' => $errorCode,
'message' => $message
];
} catch (\Exception $e) {
return ['code' => $e->getCode(), 'message' => $e->getMessage()];
@ -753,7 +867,91 @@ class Database
public function trigger_error(string $msg = 'Database Error'): void
{
$error = $this->sql_error();
$error_msg = "$msg: " . $error['message'];
// Define these variables early so they're available throughout the method
$is_admin = defined('IS_ADMIN') && IS_ADMIN;
$is_dev_mode = (defined('APP_ENV') && APP_ENV === 'local') || (defined('DBG_USER') && DBG_USER);
// Build a meaningful error message
if (!empty($error['message'])) {
$error_msg = "$msg: " . $error['message'];
if (!empty($error['code'])) {
$error_msg = "$msg ({$error['code']}): " . $error['message'];
}
} else {
// Base error message for all users
$error_msg = "$msg: Database operation failed";
// Only add detailed debugging information for administrators or in development mode
if ($is_admin || $is_dev_mode) {
// Gather detailed debugging information - ONLY for admins/developers
$debug_info = [];
// Connection status
if ($this->connection) {
$debug_info[] = "Connection: Active";
try {
$pdo = $this->connection->getPdo();
if ($pdo) {
$debug_info[] = "PDO: Available";
$errorInfo = $pdo->errorInfo();
if ($errorInfo && count($errorInfo) >= 3) {
$debug_info[] = "PDO ErrorInfo: " . json_encode($errorInfo);
}
$debug_info[] = "PDO ErrorCode: " . $pdo->errorCode();
} else {
$debug_info[] = "PDO: Null";
}
} catch (\Exception $e) {
$debug_info[] = "PDO Check Failed: " . $e->getMessage();
}
} else {
$debug_info[] = "Connection: None";
}
// Query information
if ($this->cur_query) {
$debug_info[] = "Last Query: " . substr($this->cur_query, 0, 200) . (strlen($this->cur_query) > 200 ? '...' : '');
} else {
$debug_info[] = "Last Query: None";
}
// Database information
$debug_info[] = "Database: " . ($this->selected_db ?: 'None');
$debug_info[] = "Server: " . $this->db_server;
// Recent queries from debug log (if available)
if (isset($this->debugger->dbg) && !empty($this->debugger->dbg)) {
$recent_queries = array_slice($this->debugger->dbg, -3); // Last 3 queries
$debug_info[] = "Recent Queries Count: " . count($recent_queries);
foreach ($recent_queries as $i => $query_info) {
$debug_info[] = "Query " . ($i + 1) . ": " . substr($query_info['sql'] ?? 'Unknown', 0, 100) . (strlen($query_info['sql'] ?? '') > 100 ? '...' : '');
}
}
if ($debug_info) {
$error_msg .= " [DEBUG: " . implode("; ", $debug_info) . "]";
}
// Log this for investigation
if (function_exists('bb_log')) {
bb_log("Unknown Database Error Debug:\n" . implode("\n", $debug_info), 'unknown_db_errors');
}
} else {
// For regular users: generic message only + contact admin hint
$error_msg = "$msg: A database error occurred. Please contact the administrator if this problem persists.";
// Still log basic information for debugging
if (function_exists('bb_log')) {
bb_log("Database Error (User-facing): $error_msg\nRequest: " . ($_SERVER['REQUEST_URI'] ?? 'CLI'), 'user_db_errors');
}
}
}
// Add query context for debugging (but only for admins/developers)
if ($this->cur_query && ($is_admin || $is_dev_mode)) {
$error_msg .= "\nQuery: " . $this->cur_query;
}
if (function_exists('bb_die')) {
bb_die($error_msg);
@ -797,9 +995,9 @@ class Database
/**
* Log error (delegated to debugger)
*/
public function log_error(): void
public function log_error(?\Exception $exception = null): void
{
$this->debugger->log_error();
$this->debugger->log_error($exception);
}
/**

View file

@ -29,6 +29,7 @@ class DatabaseDebugger
// Debug storage
public array $dbg = [];
public int $dbg_id = 0;
public array $legacy_queries = []; // Track queries that needed legacy compatibility fixes
// Explain functionality
public string $explain_hold = '';
@ -278,11 +279,131 @@ class DatabaseDebugger
/**
* Log error
*
* NOTE: This method logs detailed information to FILES only (error_log, bb_log).
* Log files are not accessible to regular users, so detailed information is safe here.
* User-facing error display is handled separately with proper security checks.
*/
public function log_error(): void
public function log_error(?\Exception $exception = null): void
{
$error = $this->db->sql_error();
error_log("Database Error: " . $error['message'] . " Query: " . $this->db->cur_query);
$error_details = [];
$error_msg = '';
if ($exception) {
// Use the actual exception information which is more reliable
$error_msg = "Database Error: " . $exception->getMessage();
$error_code = $exception->getCode();
if ($error_code) {
$error_msg = "Database Error ({$error_code}): " . $exception->getMessage();
}
// Collect detailed error information
$error_details[] = "Exception: " . get_class($exception);
$error_details[] = "Message: " . $exception->getMessage();
$error_details[] = "Code: " . $exception->getCode();
$error_details[] = "File: " . $exception->getFile() . ":" . $exception->getLine();
// Add PDO-specific details if it's a PDO exception
if ($exception instanceof \PDOException) {
$error_details[] = "PDO Error Info: " . json_encode($exception->errorInfo ?? []);
}
} else {
// Fallback to PDO error state (legacy behavior)
$error = $this->db->sql_error();
// Only log if there's an actual error (not 00000 which means "no error")
if (!$error['code'] || $error['code'] === '00000' || !$error['message']) {
return; // Don't log empty or "no error" states
}
$error_msg = "Database Error ({$error['code']}): " . $error['message'];
$error_details[] = "PDO Error Code: " . $error['code'];
$error_details[] = "PDO Error Message: " . $error['message'];
}
// Add comprehensive context for debugging
$error_details[] = "Query: " . ($this->db->cur_query ?: 'None');
$error_details[] = "Source: " . $this->debug_find_source();
$error_details[] = "Database: " . ($this->db->selected_db ?: 'None');
$error_details[] = "Server: " . $this->db->db_server;
$error_details[] = "Timestamp: " . date('Y-m-d H:i:s');
$error_details[] = "Request URI: " . ($_SERVER['REQUEST_URI'] ?? 'CLI');
$error_details[] = "User IP: " . ($_SERVER['REMOTE_ADDR'] ?? 'Unknown');
// Check connection status
try {
if ($this->db->connection) {
$error_details[] = "Connection Status: Active";
$pdo = $this->db->connection->getPdo();
$error_details[] = "PDO Connection: " . ($pdo ? 'Available' : 'Null');
if ($pdo) {
$errorInfo = $pdo->errorInfo();
$error_details[] = "Current PDO Error Info: " . json_encode($errorInfo);
}
} else {
$error_details[] = "Connection Status: No connection";
}
} catch (\Exception $e) {
$error_details[] = "Connection Check Failed: " . $e->getMessage();
}
// Build comprehensive log message
$log_message = $error_msg . "\n" . implode("\n", $error_details);
// Log to both error_log and TorrentPier's logging system
error_log($error_msg);
// Use TorrentPier's bb_log for better file management and organization
if (function_exists('bb_log')) {
bb_log($log_message, 'database_errors');
}
// Also log to PHP error log for immediate access
error_log("DETAILED: " . $log_message);
}
/**
* Log legacy query that needed automatic compatibility fix
*/
public function logLegacyQuery(string $query, string $error): void
{
$legacy_entry = [
'query' => $query,
'error' => $error,
'source' => $this->debug_find_source(),
'file' => $this->debug_find_source('file'),
'line' => $this->debug_find_source('line'),
'time' => microtime(true)
];
$this->legacy_queries[] = $legacy_entry;
// Mark the CURRENT debug entry as legacy instead of creating a new one
if ($this->dbg_enabled && !empty($this->dbg)) {
// Find the most recent debug entry (the one that just executed and failed)
$current_id = $this->dbg_id - 1;
if (isset($this->dbg[$current_id])) {
// Mark the existing entry as legacy
$this->dbg[$current_id]['is_legacy_query'] = true;
// Update the info to show it was automatically fixed
$original_info = $this->dbg[$current_id]['info'] ?? '';
$original_info_plain = $this->dbg[$current_id]['info_plain'] ?? $original_info;
$this->dbg[$current_id]['info'] = 'LEGACY COMPATIBILITY FIX APPLIED - ' . $original_info;
$this->dbg[$current_id]['info_plain'] = 'LEGACY COMPATIBILITY FIX APPLIED - ' . $original_info_plain;
}
}
// Log to file for permanent record
$msg = 'LEGACY QUERY DETECTED - NEEDS FIXING' . LOG_LF;
$msg .= 'Query: ' . $query . LOG_LF;
$msg .= 'Error: ' . $error . LOG_LF;
$msg .= 'Source: ' . $legacy_entry['source'] . LOG_LF;
$msg .= 'Time: ' . date('Y-m-d H:i:s', (int)$legacy_entry['time']) . LOG_LF;
bb_log($msg, 'legacy_queries', false);
}
/**

View file

@ -129,9 +129,9 @@ class Dev
private function getWhoopsOnPage(): void
{
/**
* Show errors on page
* Show errors on page with enhanced database information
*/
$prettyPageHandler = new PrettyPageHandler();
$prettyPageHandler = new \TorrentPier\Whoops\EnhancedPrettyPageHandler();
foreach (config()->get('whoops.blacklist', []) as $key => $secrets) {
foreach ($secrets as $secret) {
$prettyPageHandler->blacklist($key, $secret);
@ -195,9 +195,32 @@ class Dev
public function getSqlLogInstance(): string
{
$log = '';
$totalLegacyQueries = 0;
// Check for legacy queries across all database instances
$server_names = \TorrentPier\Database\DatabaseFactory::getServerNames();
foreach ($server_names as $srv_name) {
try {
$db_obj = \TorrentPier\Database\DatabaseFactory::getInstance($srv_name);
if (!empty($db_obj->debugger->legacy_queries)) {
$totalLegacyQueries += count($db_obj->debugger->legacy_queries);
}
} catch (\Exception $e) {
// Skip if server not available
}
}
// Add warning banner if legacy queries were detected
if ($totalLegacyQueries > 0) {
$log .= '<div style="background-color: #f8d7da; border: 1px solid #f5c6cb; color: #721c24; padding: 10px; margin-bottom: 10px; border-radius: 4px;">'
. '<strong>⚠️ Legacy Query Warning:</strong> '
. $totalLegacyQueries . ' quer' . ($totalLegacyQueries > 1 ? 'ies' : 'y') . ' with duplicate columns detected and automatically fixed. '
. 'These queries should be updated to explicitly select columns. '
. 'Check the legacy_queries.log file for details.'
. '</div>';
}
// Get debug information from new database system
$server_names = \TorrentPier\Database\DatabaseFactory::getServerNames();
foreach ($server_names as $srv_name) {
try {
$db_obj = \TorrentPier\Database\DatabaseFactory::getInstance($srv_name);
@ -262,7 +285,14 @@ class Dev
$info_plain = !empty($dbg['info_plain']) ? $dbg['info_plain'] . ' [' . $dbg['src'] . ']' : $dbg['src'];
$info = !empty($dbg['info']) ? $dbg['info'] . ' [' . $dbg['src'] . ']' : $dbg['src'];
$log .= '<div onclick="$(this).toggleClass(\'sqlHighlight\');" class="sqlLogRow" title="' . htmlspecialchars($info_plain) . '">'
// Check if this is a legacy query that needed compatibility fix
$isLegacyQuery = !empty($dbg['is_legacy_query']);
$rowClass = $isLegacyQuery ? 'sqlLogRow sqlLegacyRow' : 'sqlLogRow';
$rowStyle = $isLegacyQuery ? ' style="background-color: #ffe6e6; border-left: 4px solid #dc3545; color: #721c24;"' : '';
$legacyWarning = $isLegacyQuery ? '<span style="color: #dc3545; font-weight: bold; margin-right: 8px;">[LEGACY]</span>' : '';
$log .= '<div onclick="$(this).toggleClass(\'sqlHighlight\');" class="' . $rowClass . '" title="' . htmlspecialchars($info_plain) . '"' . $rowStyle . '>'
. $legacyWarning
. '<span style="letter-spacing: -1px;">' . $time . ' </span>'
. '<span class="copyElement" data-clipboard-target="#' . $id . '" title="Copy to clipboard" style="color: rgb(128,128,128); letter-spacing: -1px;">' . $perc . '</span>&nbsp;'
. '<span style="letter-spacing: 0;" id="' . $id . '">' . $sql . '</span>'

View file

@ -0,0 +1,353 @@
<?php
/**
* TorrentPier Bull-powered BitTorrent tracker engine
*
* @copyright Copyright (c) 2005-2025 TorrentPier (https://torrentpier.com)
* @link https://github.com/torrentpier/torrentpier for the canonical source repository
* @license https://github.com/torrentpier/torrentpier/blob/master/LICENSE MIT License
*/
namespace TorrentPier\Whoops;
use Whoops\Handler\Handler;
use Whoops\Handler\HandlerInterface;
/**
* Database Error Handler for Whoops
*
* Enhances error reporting by adding database query information,
* recent SQL activity, and database error details to the error output.
*/
class DatabaseErrorHandler extends Handler implements HandlerInterface
{
private bool $addToOutput = true;
private bool $includeQueryHistory = true;
private int $maxQueryHistory = 5;
/**
* Handle the exception and add database information
*/
public function handle(): int
{
if (!$this->addToOutput) {
return Handler::DONE;
}
$inspector = $this->getInspector();
$exception = $inspector->getException();
// Add database information to the exception frames
$this->addDatabaseContextToFrames($inspector);
// Add global database state information
$this->addGlobalDatabaseInfo($exception);
return Handler::DONE;
}
/**
* Set whether to add database info to output
*/
public function setAddToOutput(bool $add): self
{
$this->addToOutput = $add;
return $this;
}
/**
* Set whether to include query history
*/
public function setIncludeQueryHistory(bool $include): self
{
$this->includeQueryHistory = $include;
return $this;
}
/**
* Set maximum number of queries to show in history
*/
public function setMaxQueryHistory(int $max): self
{
$this->maxQueryHistory = max(1, $max);
return $this;
}
/**
* Add database context information to exception frames
*/
private function addDatabaseContextToFrames($inspector): void
{
$frames = $inspector->getFrames();
foreach ($frames as $frame) {
$frameData = [];
// Check if this frame involves database operations
$fileName = $frame->getFile();
$className = $frame->getClass();
$functionName = $frame->getFunction();
// Detect database-related frames
$isDatabaseFrame = $this->isDatabaseRelatedFrame($fileName, $className, $functionName);
if ($isDatabaseFrame) {
$frameData['database_context'] = $this->getCurrentDatabaseContext();
// Add frame-specific database info
$frame->addComment('Database Context', 'This frame involves database operations');
foreach ($frameData['database_context'] as $key => $value) {
if (is_string($value) || is_numeric($value)) {
$frame->addComment("DB: $key", $value);
} elseif (is_array($value) && !empty($value)) {
$frame->addComment("DB: $key", json_encode($value, JSON_PRETTY_PRINT));
}
}
}
}
}
/**
* Add global database information to the exception
*/
private function addGlobalDatabaseInfo($exception): void
{
try {
$databaseInfo = $this->collectDatabaseInformation();
// Use reflection to add custom data to the exception
// This will appear in the Whoops error page
if (method_exists($exception, 'setAdditionalInfo')) {
$exception->setAdditionalInfo('Database Information', $databaseInfo);
} else {
// Fallback: store in a property that Whoops can access
if (!isset($exception->databaseInfo)) {
$exception->databaseInfo = $databaseInfo;
}
}
} catch (\Exception $e) {
// Don't let database info collection break error handling
if (method_exists($exception, 'setAdditionalInfo')) {
$exception->setAdditionalInfo('Database Info Error', $e->getMessage());
}
}
}
/**
* Check if a frame is related to database operations
*/
private function isDatabaseRelatedFrame(?string $fileName, ?string $className, ?string $functionName): bool
{
if (!$fileName) {
return false;
}
// Check file paths
$databaseFiles = [
'/Database/',
'/database/',
'Database.php',
'DatabaseDebugger.php',
'DebugSelection.php',
];
foreach ($databaseFiles as $dbFile) {
if (str_contains($fileName, $dbFile)) {
return true;
}
}
// Check class names
$databaseClasses = [
'Database',
'DatabaseDebugger',
'DebugSelection',
'DB',
'Nette\Database',
];
if ($className) {
foreach ($databaseClasses as $dbClass) {
if (str_contains($className, $dbClass)) {
return true;
}
}
}
// Check function names
$databaseFunctions = [
'sql_query',
'fetch_row',
'fetch_rowset',
'sql_fetchrow',
'query',
'execute',
];
if ($functionName) {
foreach ($databaseFunctions as $dbFunc) {
if (str_contains($functionName, $dbFunc)) {
return true;
}
}
}
return false;
}
/**
* Get current database context
*/
private function getCurrentDatabaseContext(): array
{
$context = [];
try {
// Get main database instance
if (function_exists('DB')) {
$db = DB();
$context['current_query'] = $db->cur_query ?? 'None';
$context['database_server'] = $db->db_server ?? 'Unknown';
$context['selected_database'] = $db->selected_db ?? 'Unknown';
// Connection status
$context['connection_status'] = $db->connection ? 'Active' : 'No connection';
// Query stats
$context['total_queries'] = $db->num_queries ?? 0;
$context['total_time'] = isset($db->sql_timetotal) ? sprintf('%.3f sec', $db->sql_timetotal) : 'Unknown';
// Recent error information
$sqlError = $db->sql_error();
if (!empty($sqlError['message'])) {
$context['last_error'] = $sqlError;
}
}
} catch (\Exception $e) {
$context['error'] = 'Could not retrieve database context: ' . $e->getMessage();
}
return $context;
}
/**
* Collect comprehensive database information
*/
private function collectDatabaseInformation(): array
{
$info = [
'timestamp' => date('Y-m-d H:i:s'),
'request_uri' => $_SERVER['REQUEST_URI'] ?? 'CLI',
'user_ip' => $_SERVER['REMOTE_ADDR'] ?? 'Unknown',
];
try {
// Get information from all database servers
if (class_exists('\TorrentPier\Database\DatabaseFactory')) {
$serverNames = \TorrentPier\Database\DatabaseFactory::getServerNames();
foreach ($serverNames as $serverName) {
try {
$db = \TorrentPier\Database\DatabaseFactory::getInstance($serverName);
$serverInfo = [
'server_name' => $serverName,
'engine' => $db->engine ?? 'Unknown',
'host' => $db->db_server ?? 'Unknown',
'database' => $db->selected_db ?? 'Unknown',
'connection_status' => $db->connection ? 'Connected' : 'Disconnected',
'total_queries' => $db->num_queries ?? 0,
'total_time' => isset($db->sql_timetotal) ? sprintf('%.3f sec', $db->sql_timetotal) : 'Unknown',
];
// Current query
if (!empty($db->cur_query)) {
$serverInfo['current_query'] = $this->formatQueryForDisplay($db->cur_query);
}
// Last error
$sqlError = $db->sql_error();
if (!empty($sqlError['message'])) {
$serverInfo['last_error'] = $sqlError;
}
// Recent query history (if available and enabled)
if ($this->includeQueryHistory && !empty($db->dbg)) {
$recentQueries = array_slice($db->dbg, -$this->maxQueryHistory);
$serverInfo['recent_queries'] = [];
foreach ($recentQueries as $query) {
$serverInfo['recent_queries'][] = [
'sql' => $this->formatQueryForDisplay($query['sql'] ?? 'Unknown'),
'time' => isset($query['time']) ? sprintf('%.3f sec', $query['time']) : 'Unknown',
'source' => $query['src'] ?? 'Unknown',
];
}
}
$info['databases'][$serverName] = $serverInfo;
} catch (\Exception $e) {
$info['databases'][$serverName] = [
'error' => 'Could not retrieve info: ' . $e->getMessage()
];
}
}
}
// Legacy single database support
if (function_exists('DB') && empty($info['databases'])) {
$db = DB();
$info['legacy_database'] = [
'engine' => $db->engine ?? 'Unknown',
'host' => $db->db_server ?? 'Unknown',
'database' => $db->selected_db ?? 'Unknown',
'connection_status' => $db->connection ? 'Connected' : 'Disconnected',
'total_queries' => $db->num_queries ?? 0,
'total_time' => isset($db->sql_timetotal) ? sprintf('%.3f sec', $db->sql_timetotal) : 'Unknown',
];
if (!empty($db->cur_query)) {
$info['legacy_database']['current_query'] = $this->formatQueryForDisplay($db->cur_query);
}
$sqlError = $db->sql_error();
if (!empty($sqlError['message'])) {
$info['legacy_database']['last_error'] = $sqlError;
}
}
} catch (\Exception $e) {
$info['collection_error'] = $e->getMessage();
}
return $info;
}
/**
* Format SQL query for readable display
*/
private function formatQueryForDisplay(string $query, int $maxLength = 500): string
{
// Remove comments at the start (debug info)
$query = preg_replace('#^/\*.*?\*/#', '', $query);
$query = trim($query);
// Truncate if too long
if (strlen($query) > $maxLength) {
$query = substr($query, 0, $maxLength) . '... [truncated]';
}
return $query;
}
/**
* Get priority - run after the main PrettyPageHandler
*/
public function contentType(): ?string
{
return 'text/html';
}
}

View file

@ -0,0 +1,269 @@
<?php
/**
* TorrentPier Bull-powered BitTorrent tracker engine
*
* @copyright Copyright (c) 2005-2025 TorrentPier (https://torrentpier.com)
* @link https://github.com/torrentpier/torrentpier for the canonical source repository
* @license https://github.com/torrentpier/torrentpier/blob/master/LICENSE MIT License
*/
namespace TorrentPier\Whoops;
use Whoops\Handler\PrettyPageHandler;
/**
* Enhanced PrettyPageHandler for TorrentPier
*
* Extends Whoops' default handler to include database query information
* and other TorrentPier-specific debugging details in the error output.
*/
class EnhancedPrettyPageHandler extends PrettyPageHandler
{
public function __construct()
{
parent::__construct();
// Add TorrentPier-specific database information
// Note: We add these during handle() to ensure they're fresh and available
}
/**
* Get comprehensive database information
*/
private function getDatabaseInformation(): array
{
$info = [];
try {
// Get main database instance information
if (function_exists('DB')) {
$db = DB();
$info['Connection Status'] = $db->connection ? 'Connected' : 'Disconnected';
$info['Database Server'] = $db->db_server ?? 'Unknown';
$info['Selected Database'] = $db->selected_db ?? 'Unknown';
$info['Database Engine'] = $db->engine ?? 'Unknown';
$info['Total Queries'] = $db->num_queries ?? 0;
if (isset($db->sql_timetotal)) {
$info['Total Query Time'] = sprintf('%.3f seconds', $db->sql_timetotal);
}
// Current/Last executed query
if (!empty($db->cur_query)) {
$info['Current Query'] = $this->formatSqlQuery($db->cur_query);
}
// Database error information
$sqlError = $db->sql_error();
if (!empty($sqlError['message'])) {
$info['Last Database Error'] = [
'Code' => $sqlError['code'] ?? 'Unknown',
'Message' => $sqlError['message'],
];
}
// Connection details if available
if ($db->connection) {
try {
$pdo = $db->connection->getPdo();
if ($pdo) {
$info['PDO Driver'] = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) ?? 'Unknown';
$info['Server Version'] = $pdo->getAttribute(\PDO::ATTR_SERVER_VERSION) ?? 'Unknown';
// Current PDO error state
$errorCode = $pdo->errorCode();
if ($errorCode && $errorCode !== '00000') {
$errorInfo = $pdo->errorInfo();
$info['PDO Error State'] = [
'Code' => $errorCode,
'Info' => $errorInfo[2] ?? 'Unknown'
];
}
}
} catch (\Exception $e) {
$info['PDO Error'] = $e->getMessage();
}
}
}
// Get information from all database servers (new system)
if (class_exists('\TorrentPier\Database\DatabaseFactory')) {
try {
$serverNames = \TorrentPier\Database\DatabaseFactory::getServerNames();
if (count($serverNames) > 1) {
foreach ($serverNames as $serverName) {
try {
$db = \TorrentPier\Database\DatabaseFactory::getInstance($serverName);
$info["Server: $serverName"] = [
'Host' => $db->db_server ?? 'Unknown',
'Database' => $db->selected_db ?? 'Unknown',
'Queries' => $db->num_queries ?? 0,
'Connected' => $db->connection ? 'Yes' : 'No',
];
} catch (\Exception $e) {
$info["Server: $serverName"] = ['Error' => $e->getMessage()];
}
}
}
} catch (\Exception $e) {
$info['Multi-Server Error'] = $e->getMessage();
}
}
} catch (\Exception $e) {
$info['Collection Error'] = $e->getMessage();
}
return $info;
}
/**
* Get recent SQL queries from debug log
*/
private function getRecentSqlQueries(): array
{
$queries = [];
try {
if (function_exists('DB')) {
$db = DB();
// Check if debug information is available
if (!empty($db->dbg) && is_array($db->dbg)) {
// Get last 5 queries
$recentQueries = array_slice($db->dbg, -5);
foreach ($recentQueries as $index => $queryInfo) {
$queryNum = $index + 1;
$queries["Query #$queryNum"] = [
'SQL' => $this->formatSqlQuery($queryInfo['sql'] ?? 'Unknown'),
'Time' => isset($queryInfo['time']) ? sprintf('%.3f sec', $queryInfo['time']) : 'Unknown',
'Source' => $queryInfo['src'] ?? 'Unknown',
'Info' => $queryInfo['info'] ?? '',
];
// Add memory info if available
if (isset($queryInfo['mem_before'], $queryInfo['mem_after'])) {
$memUsed = $queryInfo['mem_after'] - $queryInfo['mem_before'];
$queries["Query #$queryNum"]['Memory'] = sprintf('%+d bytes', $memUsed);
}
}
}
if (empty($queries)) {
$queries['Info'] = 'No query debug information available. Enable debug mode to see recent queries.';
}
}
} catch (\Exception $e) {
$queries['Error'] = $e->getMessage();
}
return $queries;
}
/**
* Get TorrentPier environment information
*/
private function getTorrentPierEnvironment(): array
{
$env = [];
try {
// Basic environment
$env['Application Environment'] = defined('APP_ENV') ? APP_ENV : 'Unknown';
$env['Debug Mode'] = defined('DBG_USER') && DBG_USER ? 'Enabled' : 'Disabled';
$env['SQL Debug'] = defined('SQL_DEBUG') && SQL_DEBUG ? 'Enabled' : 'Disabled';
// Configuration status
if (function_exists('config')) {
$config = config();
$env['Config Loaded'] = 'Yes';
$env['TorrentPier Version'] = $config->get('tp_version', 'Unknown');
$env['Board Title'] = $config->get('sitename', 'Unknown');
} else {
$env['Config Loaded'] = 'No';
}
// Cache system
if (function_exists('CACHE')) {
$env['Cache System'] = 'Available';
}
// Language system
if (function_exists('lang')) {
$env['Language System'] = 'Available';
if (isset(lang()->getCurrentLanguage)) {
$env['Current Language'] = lang()->getCurrentLanguage;
}
}
// Memory and timing
if (defined('TIMESTART')) {
$env['Execution Time'] = sprintf('%.3f sec', microtime(true) - TIMESTART);
}
if (function_exists('sys')) {
// Use plain text formatting for memory values (no HTML entities)
$env['Peak Memory'] = str_replace('&nbsp;', ' ', humn_size(sys('mem_peak')));
$env['Current Memory'] = str_replace('&nbsp;', ' ', humn_size(sys('mem')));
}
// Request information
$env['Request Method'] = $_SERVER['REQUEST_METHOD'] ?? 'Unknown';
$env['Request URI'] = $_SERVER['REQUEST_URI'] ?? 'CLI';
$env['User Agent'] = $_SERVER['HTTP_USER_AGENT'] ?? 'Unknown';
$env['Remote IP'] = $_SERVER['REMOTE_ADDR'] ?? 'Unknown';
} catch (\Exception $e) {
$env['Error'] = $e->getMessage();
}
return $env;
}
/**
* Format SQL query for display
*/
private function formatSqlQuery(string $query): string
{
// Remove debug comments
$query = preg_replace('#^/\*.*?\*/#', '', $query);
$query = trim($query);
// Truncate very long queries but keep them readable
if (strlen($query) > 1000) {
return substr($query, 0, 1000) . "\n... [Query truncated - " . (strlen($query) - 1000) . " more characters]";
}
return $query;
}
/**
* Override parent method to add database info and custom styling
*/
public function handle()
{
// Add TorrentPier-specific database information dynamically
try {
$this->addDataTable('Database Information', $this->getDatabaseInformation());
} catch (\Exception $e) {
$this->addDataTable('Database Information', ['Error' => $e->getMessage()]);
}
try {
$this->addDataTable('Recent SQL Queries', $this->getRecentSqlQueries());
} catch (\Exception $e) {
$this->addDataTable('Recent SQL Queries', ['Error' => $e->getMessage()]);
}
try {
$this->addDataTable('TorrentPier Environment', $this->getTorrentPierEnvironment());
} catch (\Exception $e) {
$this->addDataTable('TorrentPier Environment', ['Error' => $e->getMessage()]);
}
return parent::handle();
}
}

131
src/Whoops/README.md Normal file
View file

@ -0,0 +1,131 @@
# TorrentPier Whoops Enhanced Error Reporting
This directory contains enhanced Whoops error handlers specifically designed for TorrentPier to provide better debugging information when database errors occur.
## Features
### Enhanced Database Error Reporting
The enhanced Whoops handlers provide comprehensive database information when errors occur:
1. **Current SQL Query** - Shows the exact query that caused the error
2. **Recent Query History** - Displays the last 5 SQL queries executed
3. **Database Connection Status** - Connection state, server info, database name
4. **Error Context** - PDO error codes, exception details, source location
5. **TorrentPier Environment** - Debug mode status, system information
### Components
#### EnhancedPrettyPageHandler
Extends Whoops' default `PrettyPageHandler` to include:
- **Database Information** table with connection details and current query
- **Recent SQL Queries** table showing query history with timing
- **TorrentPier Environment** table with system status
#### DatabaseErrorHandler
Specialized handler that:
- Adds database context to exception stack frames
- Identifies database-related code in the call stack
- Collects comprehensive database state information
- Formats SQL queries for readable display
## Usage
The enhanced handlers are automatically activated when `DBG_USER` is enabled in TorrentPier configuration.
### Automatic Integration
```php
// In src/Dev.php - automatically configured
$prettyPageHandler = new \TorrentPier\Whoops\EnhancedPrettyPageHandler();
```
### Database Error Logging
Database errors are now automatically logged even when they occur in Nette Database layer:
```php
// Enhanced error handling in Database.php
try {
$row = $result->fetch();
} catch (\Exception $e) {
// Log the error including the query that caused it
$this->debugger->log_error($e);
throw $e; // Re-throw for Whoops display
}
```
## Error Information Displayed
When a database error occurs, Whoops will now show:
### Database Information
- Connection Status: Connected/Disconnected
- Database Server: Host and port information
- Selected Database: Current database name
- Database Engine: MySQL/PostgreSQL/etc.
- Total Queries: Number of queries executed
- Total Query Time: Cumulative execution time
- Current Query: The SQL that caused the error
- Last Database Error: Error code and message
- PDO Driver: Database driver information
- Server Version: Database server version
### Recent SQL Queries
- **Query #1-5**: Last 5 queries executed
- SQL: Formatted query text
- Time: Execution time in seconds
- Source: File and line where query originated
- Info: Additional query information
- Memory: Memory usage if available
### TorrentPier Environment
- Application Environment: local/production/etc.
- Debug Mode: Enabled/Disabled
- SQL Debug: Enabled/Disabled
- TorrentPier Version: Current version
- Config Loaded: Configuration status
- Cache System: Availability status
- Language System: Status and encoding
- Template System: Twig-based availability
- Execution Time: Request processing time
- Peak Memory: Maximum memory used
- Current Memory: Current memory usage
- Request Method: GET/POST/etc.
- Request URI: Current page
- User Agent: Browser information
- Remote IP: Client IP address
## Configuration
The enhanced handlers respect TorrentPier's debug configuration:
- `DBG_USER`: Must be enabled to show enhanced error pages
- `SQL_DEBUG`: Enables SQL query logging and timing
- `APP_ENV`: Determines environment-specific features
## Logging
Database errors are now logged in multiple locations:
1. **PHP Error Log**: Basic error message
2. **TorrentPier bb_log**: Detailed error with context (`database_errors.log`)
3. **Whoops Log**: Complete error details (`php_whoops.log`)
## Security
The enhanced handlers maintain security by:
- Only showing detailed information when `DBG_USER` is enabled
- Using Whoops' blacklist for sensitive data
- Logging detailed information to files (not user-accessible)
- Providing generic error messages to non-debug users
## Backward Compatibility
All enhancements are:
- **100% backward compatible** with existing TorrentPier code
- **Non-breaking** - existing error handling continues to work
- **Optional** - only activated in debug mode
- **Safe** - no security implications for production use