PluginProbe
404 Solution / 4.1.19
404 Solution v4.1.19
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / DataAccessTrait_SqlErrorReporting.php

DataAccessTrait_SqlErrorReporting.php in 404 Solution 4.1.19, at includes/DataAccessTrait_SqlErrorReporting.php

140 lines 6.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * SQL error reporting helpers for DataAccess.
4 *
5 * Extracted from DataAccess.php to keep the main class under the file-size
6 * limit. All methods are called via $this-> from DataAccess (trait context)
7 * and rely on sibling traits: ErrorClassificationTrait for the isXxxError()
8 * predicates, plus the host class's $this->logger and extractSqlFilename().
9 *
10 * @since 4.1.8
11 */
12
13 if (!defined('ABSPATH')) {
14 exit;
15 }
16
17 trait ABJ_404_Solution_DataAccess_SqlErrorReportingTrait {
18
19 /**
20 * Log the first observed database error for every query, before retry and
21 * recovery paths can mutate or clear wpdb::last_error.
22 *
23 * @param string $query
24 * @param array<string, mixed> $result
25 * @param array<string, mixed> $options
26 * @param bool $producesRows
27 * @return void
28 */
29 private function logObservedSqlError(string $query, array $result, array $options, bool $producesRows): void {
30 $lastError = isset($result['last_error']) && is_string($result['last_error'])
31 ? trim($result['last_error']) : '';
32 if ($lastError === '') {
33 return;
34 }
35
36 // Honor an explicit log_errors=false: the caller has accepted that
37 // this query may fail and does not want the failure routed through
38 // Logging::errorMessage() (which can email the developer and surface
39 // admin notices). The error is still returned in $result['last_error']
40 // so callers can react to it. May 2026: a regression in this function
41 // was emailing 35 of 38 4.1.15 sites about benign SHOW CREATE TABLE
42 // probes of the transient view_build table. log_errors=false must
43 // mean "do not log".
44 $logErrors = !array_key_exists('log_errors', $options) || (bool)$options['log_errors'];
45 if (!$logErrors) {
46 return;
47 }
48
49 // Honor ignore_errors: callers pass substring patterns for errors
50 // that are expected and benign for that query (e.g. RENAME TABLE
51 // with ignore_errors=["already exists"] in renameAbj404TablesToLowerCase
52 // when the lowercase target name pre-exists). Without this check,
53 // the observation logger fires ERROR before the downstream
54 // ignore_errors branch can suppress, which emails the developer.
55 // May 2026: 16+ sites in the email-flood cohort hit this path.
56 $ignoreErrorStrings = isset($options['ignore_errors']) && is_array($options['ignore_errors'])
57 ? $options['ignore_errors'] : array();
58 foreach ($ignoreErrorStrings as $needle) {
59 if (is_string($needle) && $needle !== '' && strpos($lastError, $needle) !== false) {
60 return;
61 }
62 }
63
64 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
65 $elapsed = isset($result['elapsed_time']) && is_numeric($result['elapsed_time'])
66 ? round((float)$result['elapsed_time'], 4) : 0;
67 $message = 'SQL query error observed: ' . $lastError
68 . ', SQL: ' . $sqlInfo
69 . ', source: ' . $this->extractSqlFilename($query)
70 . ', route: ' . ($producesRows ? 'get_results' : 'query')
71 . ', log_errors_option: ' . ($logErrors ? 'true' : 'false') /** @phpstan-ignore ternary.alwaysTrue */
72 . ', execution_time: ' . $elapsed;
73
74 // Infrastructure errors (collation mismatch, disk full, read-only,
75 // host quota, deadlock, transient connection drop, etc.) are server
76 // and host issues, not plugin bugs. Log as WARN so they appear in
77 // the debug log without triggering Logging::errorMessage() email
78 // reports. The downstream code at queryAndGetResults() lines 1067+
79 // already classifies these for its own reporting branch; doing the
80 // same classification here keeps the two layers consistent.
81 // May 2026: 4 of 38 4.1.13 sites in the email-flood cohort were
82 // "Illegal mix of collations" reports that should have been WARN.
83 if ($this->isInfrastructureSqlError($lastError)) {
84 $this->logger->warn($message);
85 return;
86 }
87
88 $this->logger->errorMessage($message);
89 }
90
91 /**
92 * Pure classifier: true if the SQL error string matches a known
93 * infrastructure / host / hosting-environment failure pattern. These
94 * are conditions the plugin can detect and degrade past, not plugin
95 * bugs. Centralizing the union here keeps logObservedSqlError() and
96 * the downstream reporting branch in queryAndGetResults() consistent.
97 *
98 * @param string $errorText
99 * @return bool
100 */
101 private function isInfrastructureSqlError(string $errorText): bool {
102 if ($errorText === '') {
103 return false;
104 }
105 return $this->isDiskFullError($errorText)
106 || $this->isReadOnlyError($errorText)
107 || $this->isQuotaLimitError($errorText)
108 || $this->isInvalidDataError($errorText)
109 || $this->isCollationError($errorText)
110 || $this->isMissingPluginTableError($errorText)
111 || $this->isIncorrectKeyFileError($errorText)
112 || $this->isCrashedTableError($errorText)
113 || $this->isDeadlockOrLockTimeoutError($errorText)
114 || $this->isGaleraConflictError($errorText)
115 || $this->isTransientConnectionError($errorText)
116 || $this->isQueryTimeoutError($errorText)
117 || $this->isAccessDeniedError($errorText);
118 }
119
120 /**
121 * @param string $query
122 * @param Throwable $e
123 * @param array<string, mixed> $options
124 * @param bool $producesRows
125 * @return void
126 */
127 private function logSqlThrowable(string $query, Throwable $e, array $options, bool $producesRows): void {
128 $sqlInfo = (defined('WP_DEBUG') && WP_DEBUG) ? $query : $this->extractSqlFilename($query);
129 $logErrors = !array_key_exists('log_errors', $options) || (bool)$options['log_errors'];
130 $message = 'SQL query threw exception: ' . $e->getMessage()
131 . ', SQL: ' . $sqlInfo
132 . ', source: ' . $this->extractSqlFilename($query)
133 . ', route: ' . ($producesRows ? 'get_results' : 'query')
134 . ', log_errors_option: ' . ($logErrors ? 'true' : 'false');
135
136 $exception = $e instanceof Exception ? $e : new Exception($e->getMessage(), (int)$e->getCode(), $e);
137 $this->logger->errorMessage($message, $exception);
138 }
139 }
140