PluginProbe
404 Solution / 4.2.0
404 Solution v4.2.0
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 / ViewDiagnostics.php

ViewDiagnostics.php in 404 Solution 4.2.0, at includes/ViewDiagnostics.php

424 lines 16.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Failure message formatting and diagnostic probing for view queries.
9 *
10 * Captures structured diagnostic snapshots when admin view queries fail
11 * or time out, giving support enough evidence in a single debug zip to
12 * identify the root cause without a follow-up round trip to the user.
13 */
14 class ABJ_404_Solution_ViewDiagnostics {
15
16 /** @var ABJ_404_Solution_DatabaseCore */
17 private $dbCore;
18
19 /** @param ABJ_404_Solution_DatabaseCore $dbCore */
20 public function __construct(ABJ_404_Solution_DatabaseCore $dbCore) {
21 $this->dbCore = $dbCore;
22 }
23
24 /**
25 * @param string $queryLabel
26 * @param string $query
27 * @param array<string, mixed> $result
28 * @return string
29 */
30 public function formatViewQueryFailureMessage(string $queryLabel, string $query, array $result): string {
31 $lastErrorRaw = $result['last_error'] ?? '';
32 $lastError = is_string($lastErrorRaw) ? trim($lastErrorRaw) : '';
33 $timedOut = !empty($result['timed_out']);
34 $sqlSource = $this->dbCore->extractSqlFilename($query);
35
36 if ($lastError === '' && $timedOut) {
37 $lastError = $queryLabel . ' timed out';
38 } else if ($lastError === '') {
39 $lastError = $queryLabel . ' failed without a database error message';
40 }
41
42 return $queryLabel . ' failed'
43 . '; last_error=' . $lastError
44 . '; timed_out=' . ($timedOut ? 'true' : 'false')
45 . '; sql_source=' . $sqlSource;
46 }
47
48 /**
49 * Capture a structured diagnostics snapshot when getRedirectsForView() or
50 * getRedirectsForViewCount() fails or times out.
51 *
52 * @param string $sub
53 * @param string $failedQuery
54 * @param array<string, mixed> $tableOptions
55 * @param array<string, mixed> $queryResult
56 * @return array<string, mixed>
57 */
58 public function captureViewQueryFailureDiagnostics(string $sub, string $failedQuery, array $tableOptions, array $queryResult): array {
59 $diag = array(
60 'failed_query_label' => '',
61 'failed_query_redacted' => '',
62 'last_error' => '',
63 'timed_out' => false,
64 'elapsed_time_seconds' => null,
65 'sub' => $sub,
66 'redirects_count' => array('active' => null, 'trashed' => null),
67 'logsv2_count' => null,
68 'wp_posts_count' => null,
69 'tables' => array(),
70 'expected_indexes' => array(),
71 'canonical_url_state' => array(),
72 'db_version' => '',
73 'explain' => null,
74 );
75
76 $diag['failed_query_label'] = $this->resolveViewQueryDiagnosticLabel($failedQuery, $sub);
77 $diag['failed_query_redacted'] = $this->redactQueryShapeForDiagnostics($failedQuery);
78
79 $lastError = is_string($queryResult['last_error'] ?? null) ? $queryResult['last_error'] : '';
80 $diag['last_error'] = $lastError;
81 $diag['timed_out'] = !empty($queryResult['timed_out']);
82 if (isset($queryResult['elapsed_time']) && is_numeric($queryResult['elapsed_time'])) {
83 $diag['elapsed_time_seconds'] = (float)$queryResult['elapsed_time'];
84 }
85
86 global $wpdb;
87 $redirectsTable = $this->dbCore->doTableNameReplacements('{wp_abj404_redirects}');
88 $logsv2Table = $this->dbCore->doTableNameReplacements('{wp_abj404_logsv2}');
89 $postsTable = $this->resolvePostsTableName();
90
91 $diag['explain'] = $this->safeProbeExplain($failedQuery);
92 $diag['db_version'] = $this->safeProbeDbVersion();
93
94 $diag['redirects_count']['active'] = $this->safeProbeCount(
95 "SELECT COUNT(*) AS count FROM `" . $redirectsTable . "` WHERE disabled = 0"
96 );
97 $diag['redirects_count']['trashed'] = $this->safeProbeCount(
98 "SELECT COUNT(*) AS count FROM `" . $redirectsTable . "` WHERE disabled = 1"
99 );
100 $diag['logsv2_count'] = $this->safeProbeCount(
101 "SELECT COUNT(*) AS count FROM `" . $logsv2Table . "`"
102 );
103 if ($postsTable !== '') {
104 $diag['wp_posts_count'] = $this->safeProbeCount(
105 "SELECT COUNT(*) AS count FROM `" . $postsTable . "`"
106 );
107 }
108
109 $diag['tables'] = $this->safeProbeTableEnginesAndCollations(array($redirectsTable, $logsv2Table));
110
111 $diag['expected_indexes'] = array(
112 $redirectsTable => $this->safeProbeIndexCoverage($redirectsTable, array(
113 'PRIMARY', 'status', 'type', 'code', 'timestamp', 'disabled', 'url', 'final_dest',
114 'idx_url_disabled_status', 'idx_status_disabled', 'idx_canonical_url',
115 )),
116 $logsv2Table => $this->safeProbeIndexCoverage($logsv2Table, array(
117 'PRIMARY', 'timestamp', 'requested_url', 'username', 'min_log_id',
118 'idx_requested_url_timestamp', 'idx_canonical_url',
119 )),
120 );
121
122 $diag['canonical_url_state'] = array(
123 $redirectsTable => $this->safeProbeCanonicalUrlState($redirectsTable),
124 $logsv2Table => $this->safeProbeCanonicalUrlState($logsv2Table),
125 );
126
127 return $diag;
128 }
129
130 /**
131 * @param string $failedQuery
132 * @param string $sub
133 * @return string
134 */
135 private function resolveViewQueryDiagnosticLabel(string $failedQuery, string $sub): string {
136 if (preg_match('/\/\*\s*-+\s*(.+?\.sql)\s+BEGIN\s*-+\s*\*\//i', $failedQuery, $m)) {
137 return basename($m[1]);
138 }
139 if (stripos($failedQuery, 'COUNT(*)') !== false) {
140 return 'getRedirectsForViewCount';
141 }
142 return 'getRedirectsForView';
143 }
144
145 /**
146 * @param string $sql
147 * @return string
148 */
149 private function redactQueryShapeForDiagnostics(string $sql): string {
150 if ($sql === '') {
151 return '';
152 }
153 $out = $sql;
154 $out = preg_replace("~'(?:\\\\'|''|[^'])*'~", "?", $out) ?? $out;
155 $out = preg_replace('~"(?:\\\\"|""|[^"])*"~', "?", $out) ?? $out;
156 $out = preg_replace('~\\b0x[0-9A-Fa-f]+\\b~', '?', $out) ?? $out;
157 $out = preg_replace('~\\b\\d+(?:\\.\\d+)?\\b~', '?', $out) ?? $out;
158 $out = preg_replace('~\\(\\s*\\?\\s*(?:,\\s*\\?\\s*)+\\)~', '(?)', $out) ?? $out;
159 $out = preg_replace('~\\s+~', ' ', trim($out)) ?? $out;
160 if (strlen($out) > 4000) {
161 $out = substr($out, 0, 4000);
162 }
163 return $out;
164 }
165
166 /** @return string */
167 private function resolvePostsTableName(): string {
168 global $wpdb;
169 if (isset($wpdb->posts) && is_string($wpdb->posts) && $wpdb->posts !== '') {
170 return $wpdb->posts;
171 }
172 if (isset($wpdb->prefix) && is_string($wpdb->prefix) && $wpdb->prefix !== '') {
173 return $wpdb->prefix . 'posts';
174 }
175 return '';
176 }
177
178 /**
179 * @param string $countQuery
180 * @return int|string
181 */
182 private function safeProbeCount(string $countQuery) {
183 try {
184 $result = $this->dbCore->queryAndGetResults($countQuery, array(
185 'timeout' => 5,
186 'log_errors' => false,
187 'skip_repair' => true,
188 ));
189 $lastErrorRaw = $result['last_error'] ?? '';
190 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
191 if ($err !== '' || !empty($result['timed_out'])) {
192 return 'error: ' . ($err !== '' ? $err : 'timed out');
193 }
194 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
195 if (empty($rows)) {
196 return 0;
197 }
198 $first = is_array($rows[0]) ? $rows[0] : array();
199 $value = $first['count'] ?? $first['COUNT(*)'] ?? reset($first);
200 return is_scalar($value) ? (int)$value : 0;
201 } catch (Throwable $e) {
202 return 'error: ' . $e->getMessage();
203 }
204 }
205
206 /**
207 * @param string $failedQuery
208 * @return array<int, array<string,mixed>>|string
209 */
210 private function safeProbeExplain(string $failedQuery) {
211 if ($failedQuery === '') {
212 return 'error: no query supplied';
213 }
214 $stripped = $this->stripWrappersForExplain($failedQuery);
215 try {
216 $result = $this->dbCore->queryAndGetResults('EXPLAIN ' . $stripped, array(
217 'timeout' => 5,
218 'log_errors' => false,
219 'skip_repair' => true,
220 ));
221 $lastErrorRaw = $result['last_error'] ?? '';
222 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
223 if ($err !== '' || !empty($result['timed_out'])) {
224 return 'error: ' . ($err !== '' ? $err : 'timed out');
225 }
226 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
227 $clean = array();
228 foreach ($rows as $row) {
229 if (is_array($row)) {
230 $clean[] = $row;
231 } else if (is_object($row)) {
232 $clean[] = (array)$row;
233 }
234 }
235 return $clean;
236 } catch (Throwable $e) {
237 return 'error: ' . $e->getMessage();
238 }
239 }
240
241 /**
242 * @param string $query
243 * @return string
244 */
245 private function stripWrappersForExplain(string $query): string {
246 $q = trim($query);
247 $q = preg_replace('/^\\s*\\/\\*\\+[^*]*\\*\\/\\s*/', '', $q) ?? $q;
248 $q = preg_replace('/^\\s*SET\\s+STATEMENT\\s+max_statement_time\\s*=\\s*\\d+\\s+FOR\\s+/i', '', $q) ?? $q;
249 return $q;
250 }
251
252 /** @return string */
253 private function safeProbeDbVersion(): string {
254 try {
255 $result = $this->dbCore->queryAndGetResults('SELECT VERSION() AS version', array(
256 'timeout' => 5,
257 'log_errors' => false,
258 'skip_repair' => true,
259 ));
260 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
261 if (empty($rows)) {
262 return '';
263 }
264 $first = is_array($rows[0]) ? $rows[0] : array();
265 $value = $first['version'] ?? $first['VERSION()'] ?? reset($first);
266 return is_scalar($value) ? (string)$value : '';
267 } catch (Throwable $e) {
268 return 'error: ' . $e->getMessage();
269 }
270 }
271
272 /**
273 * @param array<int, string> $tableNames
274 * @return array<string, array{engine:string, collation:string}>
275 */
276 private function safeProbeTableEnginesAndCollations(array $tableNames): array {
277 $out = array();
278 foreach ($tableNames as $name) {
279 $out[$name] = array('engine' => '', 'collation' => '');
280 }
281 if (empty($tableNames)) {
282 return $out;
283 }
284 try {
285 $list = array();
286 foreach ($tableNames as $name) {
287 $list[] = "'" . str_replace("'", "''", $name) . "'";
288 }
289 $query = "SELECT TABLE_NAME, ENGINE, TABLE_COLLATION FROM information_schema.TABLES "
290 . "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME IN (" . implode(',', $list) . ")";
291 $result = $this->dbCore->queryAndGetResults($query, array(
292 'timeout' => 5,
293 'log_errors' => false,
294 'skip_repair' => true,
295 ));
296 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
297 foreach ($rows as $row) {
298 if (!is_array($row)) {
299 continue;
300 }
301 $name = '';
302 $engine = '';
303 $collation = '';
304 foreach ($row as $key => $value) {
305 $k = strtolower((string)$key);
306 if ($k === 'table_name' && is_scalar($value)) {
307 $name = (string)$value;
308 } else if ($k === 'engine' && is_scalar($value)) {
309 $engine = (string)$value;
310 } else if ($k === 'table_collation' && is_scalar($value)) {
311 $collation = (string)$value;
312 }
313 }
314 if ($name !== '' && array_key_exists($name, $out)) {
315 $out[$name] = array('engine' => $engine, 'collation' => $collation);
316 }
317 }
318 } catch (Throwable $e) {
319 foreach (array_keys($out) as $name) {
320 if ($out[$name]['engine'] === '') {
321 $out[$name] = array('engine' => 'error: ' . $e->getMessage(), 'collation' => '');
322 }
323 }
324 }
325 return $out;
326 }
327
328 /**
329 * @param string $tableName
330 * @param array<int, string> $expectedKeys
331 * @return array{expected: array<int,string>, present: array<int,string>, missing: array<int,string>, error?: string}
332 */
333 private function safeProbeIndexCoverage(string $tableName, array $expectedKeys): array {
334 $out = array(
335 'expected' => array_values($expectedKeys),
336 'present' => array(),
337 'missing' => array(),
338 );
339 try {
340 $result = $this->dbCore->queryAndGetResults('SHOW INDEX FROM `' . $tableName . '`', array(
341 'timeout' => 5,
342 'log_errors' => false,
343 'skip_repair' => true,
344 ));
345 $lastErrorRaw = $result['last_error'] ?? '';
346 $err = is_string($lastErrorRaw) ? $lastErrorRaw : '';
347 if ($err !== '') {
348 $out['error'] = $err;
349 $out['missing'] = $out['expected'];
350 return $out;
351 }
352 $rows = is_array($result['rows'] ?? null) ? $result['rows'] : array();
353 $present = array();
354 foreach ($rows as $row) {
355 if (!is_array($row)) {
356 continue;
357 }
358 foreach ($row as $key => $value) {
359 if (strtolower((string)$key) === 'key_name' && is_scalar($value)) {
360 $present[(string)$value] = true;
361 break;
362 }
363 }
364 }
365 $out['present'] = array_keys($present);
366 $missing = array();
367 foreach ($expectedKeys as $expected) {
368 if (!array_key_exists($expected, $present)) {
369 $missing[] = $expected;
370 }
371 }
372 $out['missing'] = $missing;
373 } catch (Throwable $e) {
374 $out['error'] = $e->getMessage();
375 $out['missing'] = $out['expected'];
376 }
377 return $out;
378 }
379
380 /**
381 * @param string $tableName
382 * @return array{column_exists: bool, null_count: int|string|null, total_count: int|string|null, error?: string}
383 */
384 private function safeProbeCanonicalUrlState(string $tableName): array {
385 $out = array(
386 'column_exists' => false,
387 'null_count' => null,
388 'total_count' => null,
389 );
390 try {
391 $colResult = $this->dbCore->queryAndGetResults('SHOW COLUMNS FROM `' . $tableName . '`', array(
392 'timeout' => 5,
393 'log_errors' => false,
394 'skip_repair' => true,
395 ));
396 $colRows = is_array($colResult['rows'] ?? null) ? $colResult['rows'] : array();
397 foreach ($colRows as $row) {
398 if (!is_array($row)) {
399 continue;
400 }
401 foreach ($row as $key => $value) {
402 if (strtolower((string)$key) === 'field' && is_scalar($value)
403 && strtolower((string)$value) === 'canonical_url') {
404 $out['column_exists'] = true;
405 break 2;
406 }
407 }
408 }
409 if (!$out['column_exists']) {
410 return $out;
411 }
412 $out['null_count'] = $this->safeProbeCount(
413 "SELECT COUNT(*) AS count FROM `" . $tableName . "` WHERE canonical_url IS NULL"
414 );
415 $out['total_count'] = $this->safeProbeCount(
416 "SELECT COUNT(*) AS count FROM `" . $tableName . "`"
417 );
418 } catch (Throwable $e) {
419 $out['error'] = $e->getMessage();
420 }
421 return $out;
422 }
423 }
424