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 / FeedbackEnvironmentExtras.php

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

1,333 lines 59.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 * Environment-extras passthrough probes for the feedback payload's JSON column.
9 *
10 * Collects best-effort diagnostic probes about the server environment
11 * (MySQL globals, disk headroom, PHP SAPI, hosting class, etc.) and
12 * packages them into a keyed array for the `environment_extras` field
13 * of the feedback payload.
14 *
15 * Each probe is wrapped by recordProbe() so a single probe failure
16 * cannot blank the others or block the support send. Failures emit a
17 * marker key `<probe>_error` with a short slug so the server side can
18 * tell "no data" from "probe failed."
19 *
20 * Used by ABJ_404_Solution_FeedbackTransport via composition:
21 * $extras = (new ABJ_404_Solution_FeedbackEnvironmentExtras())->collect();
22 *
23 * The probe set is documented in detail in
24 * docs/bruno-failure-modes-2026-05-13.md (server-side correlation
25 * targets) and pinned by tests/FeedbackTransportEnvironmentExtrasTest.
26 */
27 class ABJ_404_Solution_FeedbackEnvironmentExtras {
28
29 /**
30 * Best-effort diagnostic passthrough for the server's JSON column. The
31 * typed columns cover plugin version + WP/PHP/DB identity + content
32 * counts, but they cannot cover the operational signals that decide
33 * whether a query times out on a real shared host: MySQL memory globals
34 * (innodb_buffer_pool_size, tmp_table_size), disk headroom,
35 * and PHP SAPI specifics that the server doesn't pre-declare.
36 *
37 * Every probe is wrapped in recordProbe() so a failed lookup never
38 * blocks the support send and surfaces a `<probe>_error` marker
39 * with a short server-groupable slug. Filterable via the
40 * `abj404_environment_extras` filter so operators can append
41 * site-specific diagnostics (or strip fields for privacy) before the
42 * payload is sent.
43 *
44 * @return array<string, mixed>
45 */
46 public function collect(): array {
47 $extras = array();
48
49 // MySQL global variables: the binding constraints for slow
50 // JOIN / GROUP BY on Bruno-class sites. SHOW GLOBAL VARIABLES
51 // is read-only, no plugin tables involved.
52 $this->recordProbe($extras, 'mysql_globals', function () { return $this->collectMysqlGlobals(); }, array());
53
54 // MySQL session-variable probe persisted by the staged view-build
55 // (DataAccessTrait_ViewBuildSessionEnvProbe). Already covers
56 // tmp_table_size, max_heap_table_size, innodb_lock_wait_timeout,
57 // wait_timeout, innodb_flush_method, character_set_server.
58 // Reading the option instead of re-querying keeps the support
59 // request cheap and reflects the state of the most recent build.
60 $this->recordProbe($extras, 'mysql_session_probe', function () { return $this->loadViewBuildSessionEnvProbe(); }, array());
61
62 // Disk headroom on the WP uploads directory (where the plugin's
63 // debug log and any cron-scratch files land). "Table is full"
64 // errors are nearly always disk-quota, not the logical
65 // table-full condition.
66 $this->recordProbe($extras, 'disk_free_bytes', function () { return $this->diskFreeBytesOrThrow(); }, null);
67 $this->recordProbe($extras, 'disk_total_bytes', function () { return $this->diskTotalBytesOrThrow(); }, null);
68
69 // PHP runtime identity beyond version. SAPI distinguishes
70 // mod_php (per-request fork, fresh memory) from php-fpm
71 // (long-lived worker, opcache hot). max_input_vars caps how
72 // many POST fields the importer can accept. realpath_cache
73 // size matters for sites with many include paths.
74 $extras['php_sapi'] = function_exists('php_sapi_name') ? (string)php_sapi_name() : '';
75 $extras['php_memory_peak_bytes'] = function_exists('memory_get_peak_usage') ? (int)memory_get_peak_usage(true) : 0;
76 $extras['php_opcache_enabled'] = $this->opcacheEnabled();
77 $extras['php_max_input_vars'] = function_exists('ini_get') ? (int)ini_get('max_input_vars') : 0;
78 $extras['php_realpath_cache_size_bytes'] = function_exists('realpath_cache_size') ? (int)realpath_cache_size() : 0;
79
80 // Plugin table sizes beyond logsv2 (which has its own typed
81 // column). redirects volume and logs_hits rollup size are
82 // direct signals for the getRedirectsForViewTempTable.sql
83 // perf class.
84 $this->recordProbe($extras, 'plugin_tables_bytes', function () { return $this->collectPluginTableSizes(); }, array());
85
86 // View-build freshness signals: when did the rollup last
87 // complete, what stage did the most recent build reach, is
88 // the rollup stale relative to logsv2? Hand-assembled from
89 // plugin options the staged build already writes; no new SQL.
90 $this->recordProbe($extras, 'view_build_state', function () { return $this->collectViewBuildState(); }, array());
91
92 // SHOW PROCESSLIST row count. Indicator of shared-host MySQL
93 // saturation: a queue of 200+ idle connections explains why
94 // the staged build's BEGIN/COMMIT slots wait. Just the count;
95 // no connection details (user/host) are emitted.
96 $this->recordProbe($extras, 'active_connection_count', function () { return $this->probeActiveConnectionCount(); }, null);
97
98 // SHOW INDEX cardinality for the canonical indexes on
99 // redirects + logs_hits + logs_hits_preagg. A degraded
100 // cardinality (1 row, or NULL after a crash recovery) is a
101 // sufficient explanation for a previously-fast JOIN suddenly
102 // doing a full table scan. Shape: {table: {index: int}}.
103 $this->recordProbe($extras, 'index_cardinality', function () { return $this->probeIndexCardinality(); }, array());
104
105 // Best-effort hosting-class hint parsed from server_software
106 // and host-specific environment markers (cPanel, hPanel,
107 // Plesk, WP Engine, Kinsta, Pantheon, Flywheel, RunCloud,
108 // CloudPanel). Lets server-side group heartbeats by host
109 // class retroactively without paying for a deep fingerprint.
110 $this->recordProbe($extras, 'hosting_class', function () { return $this->probeHostingClass(); }, array());
111
112 // Object-cache backend NAME, not just the on/off enum already
113 // shipped in `object_cache`. Detect Redis / Memcached / APCu
114 // / W3TC / LiteSpeed / WP Engine native via known constants
115 // + wp_using_ext_object_cache(). Stale-cache reports cluster
116 // by backend class.
117 $this->recordProbe($extras, 'object_cache_backend', function () { return $this->probeObjectCacheBackend(); }, array());
118
119 // SHOW GLOBAL STATUS counterpart to mysql_globals. Captures
120 // runtime symptoms (lock waits, tmp-disk spills, aborted
121 // connects, slow queries) that the variables can only
122 // bound, never observe.
123 $this->recordProbe($extras, 'mysql_status', function () { return $this->probeMysqlStatus(); }, array());
124
125 // DB charset + collation, plus per-column collation on the
126 // canonical JOIN keys for redirects (url, canonical_url) and
127 // logs_hits (requested_url). Collation drift silently
128 // disables index seeks on JOIN: symptom is "fast on staging,
129 // slow on prod with identical data."
130 $this->recordProbe($extras, 'db_collation', function () { return $this->probeDbCollation(); }, array());
131
132 // WP + PHP timezone identity. Bruno-class sites in non-UTC
133 // zones (pt_BR, ja_JP) sometimes show off-by-N-hours bugs
134 // in cooldown arithmetic; capturing both lets us diff
135 // server time vs WP time vs PHP time after the fact.
136 $this->recordProbe($extras, 'timezone', function () { return $this->probeTimezone(); }, array());
137
138 // Install + upgrade history. The single most useful
139 // bifurcator for "started after upgrade Tuesday" vs
140 // "always broken since install." Read-only from plugin
141 // options the upgrade path already writes.
142 $this->recordProbe($extras, 'plugin_lifecycle', function () { return $this->probePluginLifecycle(); }, array());
143
144 // Top distinct recurring error signatures from the debug
145 // log file over the last 7 days, capped at 5 entries. The
146 // triggering error is captured by the report itself; this
147 // captures the recurring error which is often different
148 // and which the email-on-first-error path would never send.
149 $this->recordProbe($extras, 'recent_error_signatures', function () { return $this->probeRecentErrorSignatures(); }, array());
150
151 // opcache detail beyond the on/off enum already shipped
152 // in `php_opcache_enabled`. validate_timestamps=0 +
153 // revalidate_freq high explains "fresh install still
154 // buggy after upgrade" reports where the host serves
155 // cached bytecode from the prior version.
156 $this->recordProbe($extras, 'opcache_settings', function () { return $this->probeOpcacheSettings(); }, array());
157
158 // open_basedir restriction string (or null when not set).
159 // Hardened shared hosts use this to box file access;
160 // explains "permission denied" failures on paths the
161 // plugin can otherwise write.
162 $extras['open_basedir'] = $this->probeOpenBasedir();
163
164 // Multisite identity: is this the main site, what blog
165 // and network are we on, is the plugin network-activated?
166 // Behavior differs significantly across these axes
167 // (network-active vs single-site-active changes hook
168 // registration and upgrade scheduling).
169 $this->recordProbe($extras, 'multisite_role', function () { return $this->probeMultisiteRole(); }, array());
170
171 // .htaccess writability at the WP home path. When false
172 // the plugin's Apache-rule install path cannot succeed
173 // and we fall back to the DB-only redirect handler.
174 // Differentiates "redirects not firing" reports between
175 // "Apache rule never wrote" and "DB handler bug".
176 $extras['htaccess_writable'] = $this->probeHtaccessWritable();
177
178 // /tmp filesystem free bytes. Some shared hosts have
179 // separate /tmp quotas from the WP install path; tmp
180 // exhaustion breaks MySQL tmp tables (Created_tmp_disk_*
181 // counter) and PHP file uploads. disk_free_bytes on the
182 // uploads dir cannot see this.
183 $this->recordProbe($extras, 'tmp_free_bytes', function () { return $this->probeTmpFreeBytesOrThrow(); }, null);
184
185 if (function_exists('apply_filters')) {
186 $filtered = apply_filters('abj404_environment_extras', $extras);
187 if (is_array($filtered)) {
188 $extras = $filtered;
189 }
190 }
191
192 return $extras;
193 }
194
195 /**
196 * Run a probe and write either its return value into $extras[$key]
197 * on success, or a default value plus a marker $extras[$key.'_error']
198 * on failure. The marker is a short server-groupable slug
199 * ('sql_failed', 'wpdb_unavailable', 'fs_unavailable',
200 * 'invalid_shape', 'exception:<class>'), not the raw exception
201 * message. Exception text can carry PII (paths, user-supplied
202 * fragments) and we explicitly do not ship it. The raw message
203 * still goes to error_log so the host's local debugging is
204 * unaffected.
205 *
206 * Why marker keys at all: the prior pattern (tryMixedArray returning
207 * empty array) could not distinguish "probe succeeded with no data"
208 * from "probe failed and we have no signal." Markers make failure
209 * explicit so the server side does not have to guess.
210 *
211 * @param array<string,mixed> $extras
212 * @param string $key
213 * @param callable $fn
214 * @param mixed $default Value written to $extras[$key] on failure
215 * so downstream consumers can iterate without per-probe null
216 * checks.
217 * @return void
218 */
219 private function recordProbe(array &$extras, string $key, callable $fn, $default): void {
220 try {
221 $value = $fn();
222 } catch (\Throwable $e) {
223 $extras[$key] = $default;
224 $extras[$key . '_error'] = $this->classifyProbeError($e);
225 @error_log('404 Solution: FeedbackEnvironmentExtras probe "' . $key . '" failed: ' . $e->getMessage());
226 return;
227 }
228 $extras[$key] = $value;
229 }
230
231 /**
232 * Map a thrown probe exception to a short server-groupable slug.
233 * Matched on the message rather than the exception class because
234 * the probe helpers all throw \RuntimeException. The message is
235 * the differentiator. Unmatched throws degrade to
236 * 'exception:<ShortClass>' so the slug still carries fingerprint.
237 *
238 * @param \Throwable $e
239 * @return string
240 */
241 private function classifyProbeError(\Throwable $e): string {
242 $msg = strtolower((string)$e->getMessage());
243 if (strpos($msg, 'wpdb unavailable') !== false || strpos($msg, 'wpdb missing') !== false) {
244 return 'wpdb_unavailable';
245 }
246 if (strpos($msg, 'disk_free_space') !== false
247 || strpos($msg, 'disk_total_space') !== false
248 || strpos($msg, 'sys_get_temp_dir') !== false) {
249 return 'fs_unavailable';
250 }
251 if (strpos($msg, 'invalid shape') !== false
252 || strpos($msg, 'non-array') !== false
253 || strpos($msg, 'unexpected shape') !== false) {
254 return 'invalid_shape';
255 }
256 if (strpos($msg, 'sql') !== false
257 || strpos($msg, 'mysql') !== false
258 || strpos($msg, 'mariadb') !== false
259 || strpos($msg, 'query') !== false
260 || strpos($msg, 'processlist') !== false
261 || strpos($msg, 'simulated db') !== false
262 || strpos($msg, 'show global') !== false
263 || strpos($msg, 'show index') !== false
264 || strpos($msg, 'show processlist') !== false
265 || strpos($msg, 'information_schema') !== false
266 || strpos($msg, 'all tables failed') !== false
267 || strpos($msg, 'no tables probed') !== false) {
268 return 'sql_failed';
269 }
270 $shortClass = (new \ReflectionClass($e))->getShortName();
271 return 'exception:' . $shortClass;
272 }
273
274 /**
275 * Pull a fixed set of MySQL global variables relevant to staged
276 * view-build / temp-table JOIN performance on Bruno-class hosts. One
277 * SHOW GLOBAL VARIABLES query, parameterized name list, suppressed
278 * errors so a perms-denied response degrades to an empty map rather
279 * than a payload error.
280 *
281 * @return array<string, mixed>
282 */
283 private function collectMysqlGlobals(): array {
284 global $wpdb;
285 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) {
286 throw new \RuntimeException('wpdb unavailable for SHOW GLOBAL VARIABLES probe');
287 }
288 $names = array(
289 'innodb_buffer_pool_size',
290 'innodb_log_file_size',
291 'innodb_flush_method',
292 'innodb_file_per_table',
293 'innodb_lock_wait_timeout',
294 'tmp_table_size',
295 'max_heap_table_size',
296 'key_buffer_size',
297 'max_allowed_packet',
298 'sort_buffer_size',
299 'join_buffer_size',
300 'max_connections',
301 'thread_cache_size',
302 'table_open_cache',
303 'wait_timeout',
304 'interactive_timeout',
305 'character_set_server',
306 'collation_server',
307 'optimizer_switch',
308 'sql_mode',
309 'long_query_time',
310 'slow_query_log',
311 'open_files_limit',
312 );
313 $placeholders = implode(',', array_fill(0, count($names), '%s'));
314 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
315 try {
316 $prepared = "SHOW GLOBAL VARIABLES";
317 if (method_exists($wpdb, 'prepare')) {
318 // DAO-bypass-approved: SHOW GLOBAL VARIABLES placeholder bind; no plugin-table writes possible.
319 $prepared = $wpdb->prepare("SHOW GLOBAL VARIABLES WHERE Variable_name IN ($placeholders)", $names);
320 }
321 // DAO-bypass-approved: read-only probe of @@GLOBAL; no plugin tables involved.
322 $rows = $wpdb->get_results($prepared, ARRAY_A);
323 } finally {
324 if (method_exists($wpdb, 'suppress_errors')) {
325 $wpdb->suppress_errors($prevSuppress);
326 }
327 }
328
329 if (!is_array($rows)) {
330 throw new \RuntimeException('SHOW GLOBAL VARIABLES returned non-array');
331 }
332 $out = array();
333 foreach ($rows as $row) {
334 if (!is_array($row)) { continue; }
335 $name = '';
336 $value = '';
337 foreach ($row as $k => $v) {
338 $klow = strtolower((string)$k);
339 if ($klow === 'variable_name' && is_scalar($v)) { $name = strtolower((string)$v); }
340 if ($klow === 'value' && is_scalar($v)) { $value = (string)$v; }
341 }
342 if ($name === '') { continue; }
343 // Coerce numeric-looking values so the server-side JSON sort
344 // is meaningful (otherwise 9 sorts after 100 lexically).
345 if (is_numeric($value) && strpos($value, '.') === false) {
346 $out[$name] = (int)$value;
347 } elseif (is_numeric($value)) {
348 $out[$name] = (float)$value;
349 } else {
350 $out[$name] = $value;
351 }
352 }
353 return $out;
354 }
355
356 /**
357 * Read the persisted session-variable probe written at S1 entry by
358 * the staged view-build pipeline (DataAccessTrait_ViewBuildSessionEnvProbe).
359 * Reflects the most recent build's MySQL session settings without
360 * paying for a fresh SHOW SESSION VARIABLES on the support-request path.
361 *
362 * @return array<string, mixed>
363 */
364 private function loadViewBuildSessionEnvProbe(): array {
365 if (!function_exists('get_option')) {
366 return array();
367 }
368 $opt = get_option('abj404_view_build_session_env_probe', array());
369 if (!is_array($opt)) {
370 return array();
371 }
372 $out = array();
373 foreach ($opt as $k => $v) {
374 if (is_string($k)) {
375 $out[$k] = $v;
376 }
377 }
378 return $out;
379 }
380
381 /**
382 * Free bytes available on the WP uploads directory's filesystem. Used
383 * to triage "Table is full" reports (the logical table-full condition
384 * is rare, disk quota is common). Throws when disk_free_space() is
385 * disabled (open_basedir, hardened hosts) so the caller's tryInt
386 * wrapper records null rather than a misleading zero.
387 *
388 * @return int
389 */
390 private function diskFreeBytesOrThrow(): int {
391 if (!function_exists('disk_free_space')) {
392 throw new \RuntimeException('disk_free_space unavailable');
393 }
394 $dir = $this->supportDiagnosticsDirectory();
395 $v = @disk_free_space($dir);
396 if ($v === false) {
397 throw new \RuntimeException('disk_free_space returned false for ' . $dir);
398 }
399 return (int)$v;
400 }
401
402 /**
403 * Total bytes on the same filesystem. Combined with disk_free_bytes,
404 * lets the server-side report show "8% free" rather than a raw byte
405 * count that is hard to interpret across hosts.
406 *
407 * @return int
408 */
409 private function diskTotalBytesOrThrow(): int {
410 if (!function_exists('disk_total_space')) {
411 throw new \RuntimeException('disk_total_space unavailable');
412 }
413 $dir = $this->supportDiagnosticsDirectory();
414 $v = @disk_total_space($dir);
415 if ($v === false) {
416 throw new \RuntimeException('disk_total_space returned false for ' . $dir);
417 }
418 return (int)$v;
419 }
420
421 /**
422 * Best directory to probe for the plugin's filesystem headroom. The
423 * uploads dir is the most useful target (the debug log and any
424 * cron-scratch files land there), but it may not be writable in
425 * locked-down installs. Falls back to ABSPATH and finally __DIR__.
426 *
427 * @return string
428 */
429 private function supportDiagnosticsDirectory(): string {
430 if (function_exists('wp_upload_dir')) {
431 $info = wp_upload_dir(null, false);
432 if (is_array($info) && isset($info['basedir']) && is_string($info['basedir']) && $info['basedir'] !== '') {
433 return $info['basedir'];
434 }
435 }
436 if (defined('ABSPATH') && is_string(ABSPATH) && ABSPATH !== '') {
437 return ABSPATH;
438 }
439 return __DIR__;
440 }
441
442 /** @return bool */
443 private function opcacheEnabled(): bool {
444 if (function_exists('opcache_get_status')) {
445 $st = @opcache_get_status(false);
446 if (is_array($st) && isset($st['opcache_enabled'])) {
447 return (bool)$st['opcache_enabled'];
448 }
449 }
450 if (function_exists('ini_get')) {
451 $v = ini_get('opcache.enable');
452 if ($v === false) {
453 return false;
454 }
455 return ((int)$v === 1 || strtolower((string)$v) === 'on');
456 }
457 return false;
458 }
459
460 /**
461 * Size of plugin-owned tables beyond logsv2 (which has its own typed
462 * column). The redirects-tab perf bug is bounded by the
463 * redirects/logs_hits volume, not logsv2, so shipping both lets the
464 * report rank reports by the right axis.
465 *
466 * Per-table shape: {data_length: int, index_length: int, data_free: int,
467 * bytes: int}. `data_free` is the fragmentation indicator (bytes
468 * allocated to the file but not in use); a fragmentation ratio of
469 * data_free / (data_length + index_length) over ~0.3 explains the
470 * "tables are 200 MB but only 50 MB of data" long-tail slowness.
471 * `bytes` is the legacy combined data+index size kept for backward
472 * compatibility with consumers that pre-dated the data_free split.
473 *
474 * @return array<string, array<string, int>>
475 */
476 private function collectPluginTableSizes(): array {
477 global $wpdb;
478 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results') || !method_exists($wpdb, 'get_row')) {
479 throw new \RuntimeException('wpdb unavailable for plugin_tables_bytes probe');
480 }
481 $prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : 'wp_';
482 $candidates = array(
483 'redirects' => $prefix . 'abj404_redirects',
484 'logs_hits' => $prefix . 'abj404_logs_hits',
485 'logs_hits_preagg' => $prefix . 'abj404_logs_hits_preagg',
486 'permalink_cache' => $prefix . 'abj404_permalink_cache',
487 'spelling_cache' => $prefix . 'abj404_spelling_cache',
488 'lookup' => $prefix . 'abj404_lookup',
489 );
490 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
491 $out = array();
492 $errors = 0;
493 $attempted = 0;
494 foreach ($candidates as $key => $table) {
495 $attempted++;
496 try {
497 if (!method_exists($wpdb, 'prepare')) { continue; }
498 // DAO-bypass-approved: information_schema metadata probe placeholder bind; no plugin-table writes.
499 $prepared = $wpdb->prepare(
500 'SELECT data_length, index_length, data_free '
501 . 'FROM information_schema.TABLES '
502 . 'WHERE table_schema = DATABASE() AND table_name = %s',
503 $table
504 );
505 if ($prepared === null) { continue; }
506 // DAO-bypass-approved: information_schema probe; no plugin tables touched, no error class repair would apply.
507 $row = $wpdb->get_row($prepared, ARRAY_A);
508 if (!is_array($row)) { continue; }
509 $dl = 0; $il = 0; $df = 0;
510 foreach ($row as $col => $val) {
511 if (!is_scalar($val) || !is_numeric($val)) { continue; }
512 $clow = strtolower((string)$col);
513 if ($clow === 'data_length') { $dl = (int)$val; }
514 if ($clow === 'index_length') { $il = (int)$val; }
515 if ($clow === 'data_free') { $df = (int)$val; }
516 }
517 $out[$key] = array(
518 'data_length' => $dl,
519 'index_length' => $il,
520 'data_free' => $df,
521 'bytes' => $dl + $il,
522 );
523 } catch (\Throwable $e) {
524 // allow-silent-catch: per-table probe is best-effort; a missing-table or permissions error must not abort the whole map. Aggregated failure is rethrown after the loop when ALL attempts failed (see $errors check below) so the recordProbe wrapper can write the marker key.
525 @error_log('404 Solution: collectPluginTableSizes probe failed for ' . $table . ': ' . $e->getMessage());
526 $errors++;
527 }
528 }
529 if (method_exists($wpdb, 'suppress_errors')) {
530 $wpdb->suppress_errors($prevSuppress);
531 }
532 if ($errors === $attempted && empty($out)) {
533 throw new \RuntimeException('plugin_tables_bytes: all tables failed SQL probe');
534 }
535 return $out;
536 }
537
538 /**
539 * View-build freshness signals: when did the rollup last complete,
540 * what stage did the most recent build reach, and is the rollup
541 * stale relative to logsv2? Hand-assembled from plugin options the
542 * staged build already writes; no new SQL.
543 *
544 * @return array<string, int>
545 */
546 private function collectViewBuildState(): array {
547 if (!function_exists('get_option')) {
548 return array();
549 }
550 $out = array();
551 $optMap = array(
552 'last_build_completed_at' => 'abj404_view_build_last_completed_at',
553 'last_build_started_at' => 'abj404_view_build_last_started_at',
554 'last_build_stage' => 'abj404_view_build_last_stage',
555 'last_build_failure_at' => 'abj404_view_build_last_failure_at',
556 'logs_hits_max_log_id' => 'abj404_logs_hits_max_log_id',
557 );
558 foreach ($optMap as $outKey => $optName) {
559 $v = get_option($optName, null);
560 if (is_scalar($v)) {
561 $out[$outKey] = is_numeric($v) ? (int)$v : 0;
562 }
563 }
564 return $out;
565 }
566
567 /**
568 * Row count from SHOW PROCESSLIST. Cheap on a shared host (returns
569 * the current request's view of connection saturation) and a strong
570 * leading indicator for "the BEGIN/COMMIT in the staged build is
571 * waiting because there are 200 other queries in flight". Only the
572 * row count is emitted; user/host/info columns are dropped to avoid
573 * PII leakage from other tenants on the same MySQL instance.
574 *
575 * Throws when the probe genuinely cannot complete (no $wpdb, query
576 * failed) so the caller's tryInt wrapper records null rather than
577 * a misleading zero.
578 *
579 * @return int
580 */
581 private function probeActiveConnectionCount(): int {
582 global $wpdb;
583 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) {
584 throw new \RuntimeException('wpdb unavailable');
585 }
586 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
587 $rows = null;
588 try {
589 // DAO-bypass-approved: read-only probe of @@PROCESSLIST; no plugin tables involved.
590 $rows = $wpdb->get_results('SHOW PROCESSLIST', ARRAY_A);
591 } catch (\Throwable $e) {
592 // allow-silent-catch: probe is best-effort; rethrow after restoring suppress so the outer tryInt records null
593 @error_log('404 Solution: probeActiveConnectionCount failed: ' . $e->getMessage());
594 $rows = null;
595 }
596 if (method_exists($wpdb, 'suppress_errors')) {
597 $wpdb->suppress_errors($prevSuppress);
598 }
599 if (!is_array($rows)) {
600 throw new \RuntimeException('processlist probe failed');
601 }
602 return count($rows);
603 }
604
605 /**
606 * Per-index cardinality for the canonical indexes on the JOIN-hot
607 * plugin tables. Output shape:
608 * { redirects: {idx_url_disabled_status: int, idx_canonical_url: int, ...},
609 * logs_hits: {requested_url: int, ...},
610 * logs_hits_preagg: {...} }
611 *
612 * Each table is probed in its own SHOW INDEX statement, isolated in
613 * try/catch so a missing table (rebuild race, repair pending) does
614 * not blank the whole map. Only the Cardinality value is captured;
615 * the rest of the SHOW INDEX columns are not emitted.
616 *
617 * @return array<string, array<string, int>>
618 */
619 private function probeIndexCardinality(): array {
620 global $wpdb;
621 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) {
622 throw new \RuntimeException('wpdb unavailable for index_cardinality probe');
623 }
624 $prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : 'wp_';
625 $candidates = array(
626 'redirects' => $prefix . 'abj404_redirects',
627 'logs_hits' => $prefix . 'abj404_logs_hits',
628 'logs_hits_preagg' => $prefix . 'abj404_logs_hits_preagg',
629 'logsv2' => $prefix . 'abj404_logsv2',
630 );
631 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
632 $out = array();
633 $errors = 0;
634 $attempted = 0;
635 foreach ($candidates as $key => $table) {
636 $attempted++;
637 try {
638 $prepared = 'SHOW INDEX FROM `' . $table . '`';
639 if (method_exists($wpdb, 'prepare')) {
640 // DAO-bypass-approved: SHOW INDEX metadata probe placeholder bind; no plugin-table writes possible.
641 $prepared = $wpdb->prepare($prepared);
642 }
643 if ($prepared === null || $prepared === '') {
644 continue;
645 }
646 // DAO-bypass-approved: SHOW INDEX is read-only metadata; no plugin-table writes possible.
647 $rows = $wpdb->get_results($prepared, ARRAY_A);
648 if (!is_array($rows)) {
649 continue;
650 }
651 $byIndex = array();
652 foreach ($rows as $row) {
653 if (!is_array($row)) { continue; }
654 $idxName = '';
655 $card = null;
656 foreach ($row as $col => $val) {
657 $clow = strtolower((string)$col);
658 if ($clow === 'key_name' && is_scalar($val)) { $idxName = (string)$val; }
659 if ($clow === 'cardinality' && is_scalar($val) && is_numeric($val)) { $card = (int)$val; }
660 }
661 if ($idxName === '' || $card === null) { continue; }
662 if (!isset($byIndex[$idxName]) || $card > $byIndex[$idxName]) {
663 $byIndex[$idxName] = $card;
664 }
665 }
666 if (!empty($byIndex)) {
667 $out[$key] = $byIndex;
668 }
669 } catch (\Throwable $e) {
670 // allow-silent-catch: per-table probe is best-effort; a missing-table or permissions error must not abort the whole map. Aggregated failure is rethrown after the loop when ALL attempts failed (see $errors check below) so the recordProbe wrapper can write the marker key.
671 @error_log('404 Solution: probeIndexCardinality failed for ' . $table . ': ' . $e->getMessage());
672 $errors++;
673 }
674 }
675 if (method_exists($wpdb, 'suppress_errors')) {
676 $wpdb->suppress_errors($prevSuppress);
677 }
678 if ($errors === $attempted && empty($out)) {
679 throw new \RuntimeException('index_cardinality: all tables failed SHOW INDEX probe');
680 }
681 return $out;
682 }
683
684 /**
685 * Best-effort hosting-class hint. Parses well-known markers from
686 * server_software + per-host environment vars + per-host PHP
687 * constants. Returns a small object so the server side can
688 * distinguish "WP Engine" from "Kinsta" without re-parsing strings.
689 *
690 * No PII: only matched markers are returned. server_software is NOT
691 * echoed wholesale; it may include a hostname.
692 *
693 * @return array<string, mixed>
694 */
695 private function probeHostingClass(): array {
696 $out = array(
697 'host' => 'unknown',
698 'panel' => 'unknown',
699 'matched_marker' => '',
700 );
701 $sw = '';
702 if (isset($_SERVER['SERVER_SOFTWARE']) && is_scalar($_SERVER['SERVER_SOFTWARE'])) {
703 $sw = strtolower((string)$_SERVER['SERVER_SOFTWARE']);
704 }
705 // Webserver class only (no version, no hostname).
706 if (strpos($sw, 'apache') !== false) { $out['server_class'] = 'apache'; }
707 elseif (strpos($sw, 'nginx') !== false) { $out['server_class'] = 'nginx'; }
708 elseif (strpos($sw, 'litespeed') !== false){ $out['server_class'] = 'litespeed'; }
709 elseif (strpos($sw, 'iis') !== false) { $out['server_class'] = 'iis'; }
710 else { $out['server_class'] = ($sw === '' ? 'unknown' : 'other'); }
711
712 // Managed-host markers: each host publishes a distinctive
713 // constant or environment variable.
714 $managedHostChecks = array(
715 'wp_engine' => array('const' => array('WPE_APIKEY', 'WPE_PLUGIN_DIR'), 'env' => array('IS_WPE')),
716 'kinsta' => array('const' => array('KINSTA_CACHE_ZONE'), 'env' => array('KINSTA_SERVICE_NAME')),
717 'pantheon' => array('const' => array('PANTHEON_ENVIRONMENT'), 'env' => array('PANTHEON_ENVIRONMENT')),
718 'flywheel' => array('const' => array('FLYWHEEL_CONFIG_DIR', 'FLYWHEEL_PLUGIN_DIR'), 'env' => array()),
719 'pressable' => array('const' => array('PRESSABLE_VERSION'), 'env' => array()),
720 'siteground' => array('const' => array('SG_OPTIMIZER_VERSION'), 'env' => array()),
721 'wordpress_com' => array('const' => array('IS_ATOMIC', 'IS_WPCOM'), 'env' => array()),
722 'cloudways' => array('const' => array(), 'env' => array('cw_allowed_ip')),
723 );
724 foreach ($managedHostChecks as $hostKey => $checks) {
725 foreach ((array)$checks['const'] as $c) {
726 if (defined($c)) {
727 $out['host'] = $hostKey;
728 $out['matched_marker'] = 'const:' . $c;
729 break 2;
730 }
731 }
732 foreach ((array)$checks['env'] as $e) {
733 if (getenv($e) !== false) {
734 $out['host'] = $hostKey;
735 $out['matched_marker'] = 'env:' . $e;
736 break 2;
737 }
738 }
739 }
740
741 // Control-panel markers: cPanel / hPanel / Plesk / DirectAdmin /
742 // RunCloud / CloudPanel. These are independent of the managed-host
743 // class above: a cPanel site might also be on SiteGround.
744 $panelChecks = array(
745 'cpanel' => array('env' => array('CPANEL'), 'path' => array('/usr/local/cpanel')),
746 'hpanel' => array('env' => array('HOSTINGER'), 'path' => array('/usr/local/hostinger')),
747 'plesk' => array('env' => array('PLESK_ADMIN_PASSWORD'), 'path' => array('/usr/local/psa', '/opt/psa')),
748 'directadmin' => array('env' => array(), 'path' => array('/usr/local/directadmin')),
749 'runcloud' => array('env' => array(), 'path' => array('/etc/runcloud')),
750 'cloudpanel' => array('env' => array(), 'path' => array('/home/clp')),
751 );
752 foreach ($panelChecks as $panelKey => $checks) {
753 foreach ((array)$checks['env'] as $e) {
754 if (getenv($e) !== false) {
755 $out['panel'] = $panelKey;
756 if ($out['matched_marker'] === '') {
757 $out['matched_marker'] = 'env:' . $e;
758 }
759 break 2;
760 }
761 }
762 foreach ((array)$checks['path'] as $p) {
763 if (is_dir($p)) {
764 $out['panel'] = $panelKey;
765 if ($out['matched_marker'] === '') {
766 $out['matched_marker'] = 'path:' . $p;
767 }
768 break 2;
769 }
770 }
771 }
772
773 return $out;
774 }
775
776 /**
777 * Object-cache backend NAME. The base payload's `object_cache` enum
778 * answers "external or default"; this answers "external WHAT": Redis
779 * (predis vs phpredis vs Redis Object Cache plugin), Memcached,
780 * APCu, W3TC, LiteSpeed, WP Engine native, Pantheon, etc.
781 *
782 * @return array<string, mixed>
783 */
784 private function probeObjectCacheBackend(): array {
785 $out = array(
786 'using_ext_cache' => false,
787 'backend' => 'unknown',
788 'backend_detail' => '',
789 );
790 if (function_exists('wp_using_ext_object_cache')) {
791 $out['using_ext_cache'] = (bool)wp_using_ext_object_cache();
792 }
793 // Known constants/classes/extensions from popular object-cache
794 // drop-ins. Each tuple is (name, type, marker): the first match
795 // wins so a Redis Object Cache Pro install is not also tagged
796 // as plain Redis.
797 $checks = array(
798 array('redis_object_cache_pro', 'const', 'WP_REDIS_VERSION'),
799 array('redis_object_cache_pro', 'class', 'RedisCachePro\\Plugin'),
800 array('redis_object_cache', 'class', 'WP_Object_Cache'),
801 array('memcached', 'class', 'Memcached'),
802 array('apcu', 'ext', 'apcu'),
803 array('w3_total_cache', 'const', 'W3TC_VERSION'),
804 array('litespeed_cache', 'const', 'LSCWP_DIR'),
805 array('wp_engine_native', 'const', 'WPE_APIKEY'),
806 array('pantheon', 'const', 'PANTHEON_ENVIRONMENT'),
807 );
808 foreach ($checks as $check) {
809 list($name, $type, $marker) = $check;
810 if ($type === 'const' && defined($marker)) {
811 $out['backend'] = $name;
812 $out['backend_detail'] = 'const:' . $marker;
813 return $out;
814 }
815 if ($type === 'class' && class_exists($marker, false)) {
816 $out['backend'] = $name;
817 $out['backend_detail'] = 'class:' . $marker;
818 return $out;
819 }
820 if ($type === 'ext' && extension_loaded($marker)) {
821 $out['backend'] = $name;
822 $out['backend_detail'] = 'ext:' . $marker;
823 return $out;
824 }
825 }
826 // Default WP object cache used in-memory per request.
827 if (!$out['using_ext_cache']) {
828 $out['backend'] = 'default';
829 $out['backend_detail'] = 'wp_object_cache:in_memory';
830 }
831 return $out;
832 }
833
834 /**
835 * SHOW GLOBAL STATUS counterpart to mysql_globals. The variables tell
836 * us what the server is CONFIGURED to allow; the status counters tell
837 * us what is actually HAPPENING. Counters that have ticked up since
838 * boot are the strongest proximate-cause signal: lock-wait pile-ups,
839 * tmp-disk spills, aborted connects, slow queries.
840 *
841 * One SHOW GLOBAL STATUS query, parameterized name list, suppressed
842 * errors so a perms-denied response degrades to an empty map rather
843 * than a payload error.
844 *
845 * @return array<string, int>
846 */
847 private function probeMysqlStatus(): array {
848 global $wpdb;
849 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results')) {
850 throw new \RuntimeException('wpdb unavailable for SHOW GLOBAL STATUS probe');
851 }
852 $names = array(
853 'Innodb_buffer_pool_pages_dirty',
854 'Innodb_buffer_pool_pages_total',
855 'Innodb_row_lock_waits',
856 'Innodb_row_lock_time_avg',
857 'Innodb_deadlocks',
858 'Threads_running',
859 'Threads_connected',
860 'Aborted_connects',
861 'Aborted_clients',
862 'Created_tmp_disk_tables',
863 'Created_tmp_tables',
864 'Slow_queries',
865 'Table_locks_waited',
866 'Open_tables',
867 'Opened_tables',
868 'Uptime',
869 );
870 $placeholders = implode(',', array_fill(0, count($names), '%s'));
871 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
872 try {
873 $prepared = 'SHOW GLOBAL STATUS';
874 if (method_exists($wpdb, 'prepare')) {
875 // DAO-bypass-approved: SHOW GLOBAL STATUS placeholder bind; no plugin-table writes possible.
876 $prepared = $wpdb->prepare("SHOW GLOBAL STATUS WHERE Variable_name IN ($placeholders)", $names);
877 }
878 // DAO-bypass-approved: read-only probe of @@GLOBAL_STATUS; no plugin tables involved.
879 $rows = $wpdb->get_results($prepared, ARRAY_A);
880 } finally {
881 if (method_exists($wpdb, 'suppress_errors')) {
882 $wpdb->suppress_errors($prevSuppress);
883 }
884 }
885 if (!is_array($rows)) {
886 throw new \RuntimeException('SHOW GLOBAL STATUS returned non-array');
887 }
888 $out = array();
889 foreach ($rows as $row) {
890 if (!is_array($row)) { continue; }
891 $name = '';
892 $value = '';
893 foreach ($row as $k => $v) {
894 $klow = strtolower((string)$k);
895 if ($klow === 'variable_name' && is_scalar($v)) { $name = strtolower((string)$v); }
896 if ($klow === 'value' && is_scalar($v)) { $value = (string)$v; }
897 }
898 if ($name === '') { continue; }
899 if (is_numeric($value)) {
900 $out[$name] = (int)$value;
901 }
902 }
903 return $out;
904 }
905
906 /**
907 * DB-level + per-column collation for the JOIN-hot URL columns on
908 * `abj404_redirects` and `abj404_logs_hits`. Collation drift between
909 * the two columns disables the index seek silently: MySQL falls back
910 * to a full-scan ON the un-joined column. Capturing both lets the
911 * server side classify "fast on staging, slow on prod" reports by
912 * the cause that is invisible from the SHOW CREATE TABLE output.
913 *
914 * Shape:
915 * { db_charset: string, db_collate: string,
916 * columns: { '{prefix}abj404_redirects.url': string,
917 * '{prefix}abj404_redirects.canonical_url': string,
918 * '{prefix}abj404_logs_hits.requested_url': string } }
919 *
920 * @return array<string, mixed>
921 */
922 private function probeDbCollation(): array {
923 $out = array(
924 'db_charset' => defined('DB_CHARSET') && is_string(DB_CHARSET) ? DB_CHARSET : '',
925 'db_collate' => defined('DB_COLLATE') && is_string(DB_COLLATE) ? DB_COLLATE : '',
926 'columns' => array(),
927 );
928 global $wpdb;
929 if (!isset($wpdb) || !is_object($wpdb) || !method_exists($wpdb, 'get_results') || !method_exists($wpdb, 'get_var')) {
930 throw new \RuntimeException('wpdb unavailable for db_collation probe');
931 }
932 $prefix = (isset($wpdb->prefix) && is_string($wpdb->prefix)) ? $wpdb->prefix : 'wp_';
933 $targets = array(
934 $prefix . 'abj404_redirects' => array('url', 'canonical_url'),
935 $prefix . 'abj404_logs_hits' => array('requested_url'),
936 $prefix . 'abj404_logsv2' => array('url'),
937 );
938 $prevSuppress = method_exists($wpdb, 'suppress_errors') ? $wpdb->suppress_errors(true) : false;
939 $errors = 0;
940 $attempted = 0;
941 foreach ($targets as $table => $cols) {
942 foreach ($cols as $col) {
943 $attempted++;
944 try {
945 if (!method_exists($wpdb, 'prepare')) { continue; }
946 // DAO-bypass-approved: information_schema collation probe placeholder bind; no plugin-table writes.
947 $prepared = $wpdb->prepare(
948 'SELECT COLLATION_NAME '
949 . 'FROM information_schema.COLUMNS '
950 . 'WHERE table_schema = DATABASE() AND table_name = %s AND column_name = %s',
951 $table,
952 $col
953 );
954 if ($prepared === null) { continue; }
955 // DAO-bypass-approved: information_schema metadata probe; no plugin-table writes.
956 $v = $wpdb->get_var($prepared);
957 if (is_scalar($v) && (string)$v !== '') {
958 $out['columns'][$table . '.' . $col] = (string)$v;
959 }
960 } catch (\Throwable $e) {
961 // allow-silent-catch: per-column probe is best-effort; missing-table / permissions errors must not abort the whole map. Aggregated failure is rethrown after the loop when ALL attempts failed (see $errors check below) so the recordProbe wrapper can write the marker key.
962 @error_log('404 Solution: probeDbCollation probe failed for ' . $table . '.' . $col . ': ' . $e->getMessage());
963 $errors++;
964 }
965 }
966 }
967 if (method_exists($wpdb, 'suppress_errors')) {
968 $wpdb->suppress_errors($prevSuppress);
969 }
970 if ($errors === $attempted && empty($out['columns'])) {
971 throw new \RuntimeException('db_collation: all per-column SQL probes failed');
972 }
973 return $out;
974 }
975
976 /**
977 * Timezone identity for the WP install, PHP runtime, and OS. The
978 * canonical "off-by-N-hours" cron-window bug class is when WP thinks
979 * it is in pt_BR while PHP is in UTC; capturing all three lets us
980 * detect drift retroactively.
981 *
982 * @return array<string, mixed>
983 */
984 private function probeTimezone(): array {
985 $out = array(
986 'wp_timezone' => '',
987 'wp_gmt_offset' => 0,
988 'php_timezone' => '',
989 'server_utc_offset_seconds' => 0,
990 );
991 if (function_exists('get_option')) {
992 $tz = get_option('timezone_string', '');
993 if (is_scalar($tz)) { $out['wp_timezone'] = (string)$tz; }
994 $off = get_option('gmt_offset', 0);
995 if (is_scalar($off)) { $out['wp_gmt_offset'] = (int)round((float)$off * 3600); }
996 }
997 if (function_exists('date_default_timezone_get')) {
998 $out['php_timezone'] = (string)date_default_timezone_get();
999 }
1000 try {
1001 $tz = new \DateTimeZone($out['php_timezone'] !== '' ? $out['php_timezone'] : 'UTC');
1002 $dt = new \DateTime('now', $tz);
1003 $out['server_utc_offset_seconds'] = (int)$tz->getOffset($dt);
1004 } catch (\Throwable $e) {
1005 // allow-silent-catch: server_utc_offset is best-effort; an invalid tz string leaves the default zero in place
1006 @error_log('404 Solution: probeTimezone offset probe failed: ' . $e->getMessage());
1007 }
1008 return $out;
1009 }
1010
1011 /**
1012 * Install + upgrade timeline. The single most useful bifurcator for
1013 * "started after upgrade Tuesday" vs "always broken since install."
1014 * Read-only from plugin options the upgrade path already writes;
1015 * no new SQL, no new options.
1016 *
1017 * Fields:
1018 * installed_at: int|null unix seconds, from abj404_installed_time
1019 * current_version: string ABJ404_VERSION (live)
1020 * db_version_option string|null abj404_settings['DB_VERSION'] (the value
1021 * stamped at the last upgrade; equals
1022 * current_version after the upgrade path
1023 * ran, mismatches between upgrade tick
1024 * and DB_VERSION write on lock contention)
1025 *
1026 * @return array<string, mixed>
1027 */
1028 private function probePluginLifecycle(): array {
1029 $out = array(
1030 'installed_at' => null,
1031 'current_version' => defined('ABJ404_VERSION') ? (string)ABJ404_VERSION : '',
1032 'db_version_option' => null,
1033 );
1034 if (function_exists('get_option')) {
1035 $t = get_option('abj404_installed_time', null);
1036 if (is_scalar($t) && is_numeric($t)) {
1037 $out['installed_at'] = (int)$t;
1038 }
1039 $settings = get_option('abj404_settings', null);
1040 if (is_array($settings) && isset($settings['DB_VERSION']) && is_scalar($settings['DB_VERSION'])) {
1041 $out['db_version_option'] = (string)$settings['DB_VERSION'];
1042 }
1043 }
1044 return $out;
1045 }
1046
1047 /**
1048 * Top distinct recurring error signatures from the plugin's debug
1049 * log file over the last 7 days, capped at 5 entries. The triggering
1050 * error is captured by the report itself ('error_signature' on the
1051 * payload); this probe captures the recurring error which is often
1052 * different and would never reach the email-on-first-error path.
1053 *
1054 * Bounded cost: reads the tail 256 KB of the debug file, parses
1055 * lines matching the canonical "YYYY-MM-DD HH:MM:SS (LEVEL): ..."
1056 * shape, keeps only [ERROR]/[WARN] entries within the last 7 days,
1057 * groups by a coarse signature (first 200 chars after the level),
1058 * keeps the top 5 by count. Returns an empty array on any read
1059 * failure.
1060 *
1061 * Shape:
1062 * [ {signature: string, count: int, last_seen_at: int}, ... ]
1063 *
1064 * @return array<int, array<string, mixed>>
1065 */
1066 private function probeRecentErrorSignatures(): array {
1067 $out = array();
1068 try {
1069 $log = abj_service('logging');
1070 // allow-silent-catch: container miss is fatal for this probe; null check below records empty
1071 } catch (\Throwable $e) {
1072 return $out;
1073 }
1074 if (!is_object($log) || !method_exists($log, 'getDebugFilePath')) {
1075 return $out;
1076 }
1077 $path = (string)$log->getDebugFilePath();
1078 if ($path === '' || !is_file($path) || !is_readable($path)) {
1079 return $out;
1080 }
1081 $size = @filesize($path);
1082 if ($size === false || $size === 0) {
1083 return $out;
1084 }
1085 $readBytes = 262144; // 256 KB
1086 $offset = $size > $readBytes ? $size - $readBytes : 0;
1087 $fh = @fopen($path, 'rb');
1088 if (!is_resource($fh)) {
1089 return $out;
1090 }
1091 $tail = '';
1092 try {
1093 if ($offset > 0) {
1094 @fseek($fh, $offset);
1095 // Discard the partial first line so we only group on whole records.
1096 @fgets($fh);
1097 }
1098 $chunk = @fread($fh, $readBytes);
1099 if (is_string($chunk)) {
1100 $tail = $chunk;
1101 }
1102 } finally {
1103 @fclose($fh);
1104 }
1105 if ($tail === '') {
1106 return $out;
1107 }
1108 $cutoff = time() - 7 * 86400;
1109 $byKey = array();
1110 $lines = preg_split('/\r?\n/', $tail);
1111 if (!is_array($lines)) {
1112 return $out;
1113 }
1114 foreach ($lines as $line) {
1115 if (!is_string($line) || $line === '') { continue; }
1116 // Match "YYYY-MM-DD HH:MM:SS (LEVEL): tail..." per Logging.php format.
1117 if (!preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \((ERROR|WARN)\):\s*(.*)$/', $line, $m)) {
1118 continue;
1119 }
1120 $ts = strtotime($m[1]);
1121 if ($ts === false || $ts < $cutoff) { continue; }
1122 $level = $m[2];
1123 $msg = trim($m[3]);
1124 if ($msg === '') { continue; }
1125 $sig = $level . ':' . substr($this->normalizeErrorSignature($msg), 0, 200);
1126 if (!isset($byKey[$sig])) {
1127 $byKey[$sig] = array('signature' => $sig, 'count' => 0, 'last_seen_at' => 0);
1128 }
1129 $byKey[$sig]['count']++;
1130 if ($ts > $byKey[$sig]['last_seen_at']) {
1131 $byKey[$sig]['last_seen_at'] = $ts;
1132 }
1133 }
1134 if (empty($byKey)) {
1135 return $out;
1136 }
1137 $list = array_values($byKey);
1138 usort($list, function ($a, $b) {
1139 $cmp = $b['count'] - $a['count'];
1140 if ($cmp !== 0) { return $cmp; }
1141 return $b['last_seen_at'] - $a['last_seen_at'];
1142 });
1143 return array_slice($list, 0, 5);
1144 }
1145
1146 /**
1147 * Coarse-grain an error message so different incident timestamps,
1148 * memory addresses, file paths, and line numbers fold into the same
1149 * signature. Used by probeRecentErrorSignatures to group recurring
1150 * errors.
1151 *
1152 * @param string $msg
1153 * @return string
1154 */
1155 private function normalizeErrorSignature(string $msg): string {
1156 $s = $msg;
1157 // Strip absolute paths to just the basename.
1158 $s = preg_replace('#/[A-Za-z0-9_\-\./]+/([A-Za-z0-9_\-]+\.php)#', '$1', $s) ?? $s;
1159 // Collapse memory addresses, hex, and digit sequences.
1160 $s = preg_replace('/\b0x[0-9a-fA-F]+\b/', '0xN', $s) ?? $s;
1161 $s = preg_replace('/\b\d{4,}\b/', 'N', $s) ?? $s;
1162 // Collapse runs of whitespace.
1163 $s = preg_replace('/\s+/', ' ', $s) ?? $s;
1164 return trim($s);
1165 }
1166
1167 /**
1168 * opcache detail fields beyond the on/off enum. Each value is
1169 * explicitly nullable: ini_get() returns false when the directive
1170 * is unknown, and "we couldn't read it" is materially different
1171 * from a stamped 0/false the host configured deliberately.
1172 *
1173 * Shape:
1174 * { revalidate_freq: int|null,
1175 * validate_timestamps: bool|null,
1176 * enable_cli: bool|null }
1177 *
1178 * @return array<string, mixed>
1179 */
1180 private function probeOpcacheSettings(): array {
1181 $out = array(
1182 'revalidate_freq' => null,
1183 'validate_timestamps' => null,
1184 'enable_cli' => null,
1185 );
1186 if (!function_exists('ini_get')) {
1187 return $out;
1188 }
1189 $rf = ini_get('opcache.revalidate_freq');
1190 if ($rf !== false) {
1191 $out['revalidate_freq'] = (int)$rf;
1192 }
1193 $vt = ini_get('opcache.validate_timestamps');
1194 if ($vt !== false) {
1195 $out['validate_timestamps'] = ((int)$vt === 1 || strtolower((string)$vt) === 'on');
1196 }
1197 $ec = ini_get('opcache.enable_cli');
1198 if ($ec !== false) {
1199 $out['enable_cli'] = ((int)$ec === 1 || strtolower((string)$ec) === 'on');
1200 }
1201 return $out;
1202 }
1203
1204 /**
1205 * open_basedir restriction string, or null when not configured.
1206 * Returned wholesale (path list) so the server side can match it
1207 * against the plugin's known write targets; the value is not PII
1208 * and the per-host shapes vary enough that any normalization here
1209 * would lose signal.
1210 *
1211 * @return string|null
1212 */
1213 private function probeOpenBasedir(): ?string {
1214 if (!function_exists('ini_get')) {
1215 return null;
1216 }
1217 $v = ini_get('open_basedir');
1218 if (!is_string($v) || $v === '') {
1219 return null;
1220 }
1221 return $v;
1222 }
1223
1224 /**
1225 * Multisite identity for the request the report originates from.
1226 * When `is_multisite()` is false the rest of the shape is omitted
1227 * rather than emitted as nulls per probe (a single-site install
1228 * has no blog_id/network_id and the keys would be misleading).
1229 *
1230 * Shape (multisite):
1231 * { is_multisite: true,
1232 * is_main_site: bool|null,
1233 * blog_id: int|null,
1234 * network_id: int|null,
1235 * network_activated: bool|null }
1236 *
1237 * Shape (single-site):
1238 * { is_multisite: false }
1239 *
1240 * @return array<string, mixed>
1241 */
1242 private function probeMultisiteRole(): array {
1243 $isMultisite = function_exists('is_multisite') && (bool)is_multisite();
1244 $out = array('is_multisite' => $isMultisite);
1245 if (!$isMultisite) {
1246 return $out;
1247 }
1248 $out['is_main_site'] = function_exists('is_main_site') ? (bool)is_main_site() : null;
1249 $out['blog_id'] = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : null;
1250 $out['network_id'] = function_exists('get_current_network_id') ? (int)get_current_network_id() : null;
1251
1252 $networkActivated = null;
1253 if (function_exists('is_plugin_active_for_network') && function_exists('plugin_basename') && defined('ABJ404_FILE')) {
1254 try {
1255 $networkActivated = (bool) is_plugin_active_for_network(plugin_basename(ABJ404_FILE));
1256 } catch (\Throwable $e) {
1257 // allow-silent-catch: best-effort multisite probe; is_plugin_active_for_network requires wp-admin context that may not be loaded on front-end / cron paths, leave null
1258 @error_log('404 Solution: probeMultisiteRole network-activated check failed: ' . $e->getMessage());
1259 $networkActivated = null;
1260 }
1261 }
1262 $out['network_activated'] = $networkActivated;
1263 return $out;
1264 }
1265
1266 /**
1267 * Whether the .htaccess at the WP home path is writable by the
1268 * plugin. Differentiates "Apache rule install will succeed" from
1269 * "must use the DB-only redirect handler". Falls back to ABSPATH
1270 * when get_home_path() is unavailable (front-end / cron context
1271 * loads it on demand from wp-admin/includes/file.php).
1272 *
1273 * @return bool
1274 */
1275 private function probeHtaccessWritable(): bool {
1276 $path = $this->resolveHtaccessPath();
1277 if ($path === '') {
1278 return false;
1279 }
1280 // is_writable() returns false on a non-existent file too,
1281 // which matches the install-method intent: if the file does
1282 // not yet exist and we cannot write the directory either, the
1283 // Apache-rule path cannot succeed.
1284 return @is_writable($path);
1285 }
1286
1287 /**
1288 * Best path to test for .htaccess writability. Prefers
1289 * get_home_path() (which honors WordPress in-subdir installs);
1290 * falls back to ABSPATH for early-boot / front-end contexts where
1291 * wp-admin/includes/file.php has not been loaded.
1292 *
1293 * @return string
1294 */
1295 private function resolveHtaccessPath(): string {
1296 if (function_exists('get_home_path')) {
1297 $home = (string) get_home_path();
1298 if ($home !== '') {
1299 return rtrim($home, "/\\") . '/.htaccess';
1300 }
1301 }
1302 if (defined('ABSPATH') && ABSPATH !== '') {
1303 return rtrim(ABSPATH, "/\\") . '/.htaccess';
1304 }
1305 return '';
1306 }
1307
1308 /**
1309 * Free bytes on the system temp directory's filesystem. Some
1310 * shared hosts mount /tmp as a separate quota from the WP install
1311 * path; the disk_free_bytes probe (which targets the uploads dir)
1312 * cannot see /tmp exhaustion. Throws when disk_free_space is
1313 * disabled so the caller's tryInt wrapper records null rather
1314 * than a misleading zero.
1315 *
1316 * @return int
1317 */
1318 private function probeTmpFreeBytesOrThrow(): int {
1319 if (!function_exists('disk_free_space')) {
1320 throw new \RuntimeException('disk_free_space unavailable');
1321 }
1322 $tmp = function_exists('sys_get_temp_dir') ? sys_get_temp_dir() : '';
1323 if ($tmp === '') {
1324 throw new \RuntimeException('sys_get_temp_dir returned empty');
1325 }
1326 $v = @disk_free_space($tmp);
1327 if ($v === false) {
1328 throw new \RuntimeException('disk_free_space returned false for ' . $tmp);
1329 }
1330 return (int)$v;
1331 }
1332 }
1333