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

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

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