PluginProbe
404 Solution / 4.3.0
404 Solution v4.3.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 / feedback / FeedbackEnvironmentExtras.php

FeedbackEnvironmentExtras.php in 404 Solution 4.3.0, at includes/feedback/FeedbackEnvironmentExtras.php

314 lines 16.1 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 require_once __DIR__ . '/FeedbackEnvironmentExtras_DbProbes.php';
8 require_once __DIR__ . '/FeedbackEnvironmentExtras_HostProbes.php';
9 require_once __DIR__ . '/FeedbackEnvironmentExtras_PlatformFingerprint.php';
10 require_once __DIR__ . '/FeedbackEnvironmentExtras_DebugLogSignatures.php';
11
12 /**
13 * Environment-extras passthrough probes for the feedback payload's JSON column.
14 *
15 * Orchestrates a set of best-effort diagnostic probes about the server
16 * environment (MySQL globals, disk headroom, PHP SAPI, hosting class,
17 * etc.) and packages them into a keyed array for the `environment_extras`
18 * field of the feedback payload.
19 *
20 * This class owns ONLY the probe registry and the failure-isolation
21 * wrapper. The probe implementations live in four collaborator classes,
22 * partitioned by data source and lifecycle:
23 * - FeedbackEnvironmentExtras_DbProbes: MySQL/MariaDB probes via $wpdb
24 * (SHOW GLOBAL VARIABLES/STATUS, SHOW PROCESSLIST, SHOW INDEX,
25 * information_schema, view-build option signals).
26 * - FeedbackEnvironmentExtras_HostProbes: dynamic PHP/OS/WP runtime
27 * state (opcache, filesystem headroom, open_basedir, timezone,
28 * multisite role, htaccess writability, lifecycle).
29 * - FeedbackEnvironmentExtras_PlatformFingerprint: static platform
30 * identity (hosting class, control panel, object-cache backend) --
31 * marker-table scans that rarely change for the life of the install.
32 * - FeedbackEnvironmentExtras_DebugLogSignatures: tail-read of the
33 * plugin debug log + PII-stripping signature normalization for
34 * `recent_error_signatures`.
35 *
36 * Each probe is wrapped by recordProbe() so a single probe failure
37 * cannot blank the others or block the support send. Failures emit a
38 * marker key `<probe>_error` with a short slug so the server side can
39 * tell "no data" from "probe failed."
40 *
41 * Used by ABJ_404_Solution_FeedbackTransport via composition:
42 * $extras = (new ABJ_404_Solution_FeedbackEnvironmentExtras())->collect();
43 *
44 * The probe set is documented in detail in
45 * docs/bruno-failure-modes-2026-05-13.md (server-side correlation
46 * targets) and pinned by tests/FeedbackTransportEnvironmentExtrasTest.
47 */
48 class ABJ_404_Solution_FeedbackEnvironmentExtras {
49
50 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_DbProbes */
51 private $db;
52
53 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_HostProbes */
54 private $host;
55
56 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_PlatformFingerprint */
57 private $platform;
58
59 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures */
60 private $debugLog;
61
62 public function __construct() {
63 $this->db = new ABJ_404_Solution_FeedbackEnvironmentExtras_DbProbes();
64 $this->host = new ABJ_404_Solution_FeedbackEnvironmentExtras_HostProbes();
65 $this->platform = new ABJ_404_Solution_FeedbackEnvironmentExtras_PlatformFingerprint();
66 $this->debugLog = new ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures();
67 }
68
69 /**
70 * Best-effort diagnostic passthrough for the server's JSON column. The
71 * typed columns cover plugin version + WP/PHP/DB identity + content
72 * counts, but they cannot cover the operational signals that decide
73 * whether a query times out on a real shared host: MySQL memory globals
74 * (innodb_buffer_pool_size, tmp_table_size), disk headroom,
75 * and PHP SAPI specifics that the server doesn't pre-declare.
76 *
77 * Every probe is wrapped in recordProbe() so a failed lookup never
78 * blocks the support send and surfaces a `<probe>_error` marker
79 * with a short server-groupable slug. Filterable via the
80 * `abj404_environment_extras` filter so operators can append
81 * site-specific diagnostics (or strip fields for privacy) before the
82 * payload is sent.
83 *
84 * @return array<string, mixed>
85 */
86 public function collect(): array {
87 $extras = array();
88 $db = $this->db;
89 $host = $this->host;
90 $platform = $this->platform;
91 $debugLog = $this->debugLog;
92
93 // MySQL global variables: the binding constraints for slow
94 // JOIN / GROUP BY on Bruno-class sites. SHOW GLOBAL VARIABLES
95 // is read-only, no plugin tables involved.
96 $this->recordProbe($extras, 'mysql_globals', function () use ($db) { return $db->collectMysqlGlobals(); }, array());
97
98 // MySQL session-variable probe persisted by older builds. Reading the
99 // option instead of re-querying keeps the support request cheap when
100 // historical probe data is present.
101 $this->recordProbe($extras, 'mysql_session_probe', function () use ($db) { return $db->loadViewBuildSessionEnvProbe(); }, array());
102
103 // Disk headroom on the WP uploads directory (where the plugin's
104 // debug log and any cron-scratch files land). "Table is full"
105 // errors are nearly always disk-quota, not the logical
106 // table-full condition.
107 $this->recordProbe($extras, 'disk_free_bytes', function () use ($host) { return $host->diskFreeBytesOrThrow(); }, null);
108 $this->recordProbe($extras, 'disk_total_bytes', function () use ($host) { return $host->diskTotalBytesOrThrow(); }, null);
109
110 // PHP runtime identity beyond version. SAPI distinguishes
111 // mod_php (per-request fork, fresh memory) from php-fpm
112 // (long-lived worker, opcache hot). max_input_vars caps how
113 // many POST fields the importer can accept. realpath_cache
114 // size matters for sites with many include paths.
115 $extras['php_sapi'] = function_exists('php_sapi_name') ? (string)php_sapi_name() : '';
116 $extras['php_memory_peak_bytes'] = function_exists('memory_get_peak_usage') ? (int)memory_get_peak_usage(true) : 0;
117 $extras['php_opcache_enabled'] = $host->opcacheEnabled();
118 $extras['php_max_input_vars'] = function_exists('ini_get') ? (int)ini_get('max_input_vars') : 0;
119 $extras['php_realpath_cache_size_bytes'] = function_exists('realpath_cache_size') ? (int)realpath_cache_size() : 0;
120
121 // Plugin table sizes beyond logsv2 (which has its own typed
122 // column). redirects volume and logs_hits rollup size are
123 // direct signals for the getRedirectsForViewTempTable.sql
124 // perf class.
125 $this->recordProbe($extras, 'plugin_tables_bytes', function () use ($db) { return $db->collectPluginTableSizes(); }, array());
126
127 // View-build freshness signals: when did the rollup last
128 // complete, what stage did the most recent build reach, is
129 // the rollup stale relative to logsv2? Hand-assembled from
130 // plugin options the staged build already writes; no new SQL.
131 $this->recordProbe($extras, 'view_build_state', function () use ($db) { return $db->collectViewBuildState(); }, array());
132
133 // SHOW PROCESSLIST row count. Indicator of shared-host MySQL
134 // saturation: a queue of 200+ idle connections explains why
135 // the staged build's BEGIN/COMMIT slots wait. Just the count;
136 // no connection details (user/host) are emitted.
137 $this->recordProbe($extras, 'active_connection_count', function () use ($db) { return $db->probeActiveConnectionCount(); }, null);
138
139 // SHOW INDEX cardinality for the canonical indexes on
140 // redirects + logs_hits + logs_hits_preagg. A degraded
141 // cardinality (1 row, or NULL after a crash recovery) is a
142 // sufficient explanation for a previously-fast JOIN suddenly
143 // doing a full table scan. Shape: {table: {index: int}}.
144 $this->recordProbe($extras, 'index_cardinality', function () use ($db) { return $db->probeIndexCardinality(); }, array());
145
146 // Best-effort hosting-class hint parsed from server_software
147 // and host-specific environment markers (cPanel, hPanel,
148 // Plesk, WP Engine, Kinsta, Pantheon, Flywheel, RunCloud,
149 // CloudPanel). Lets server-side group heartbeats by host
150 // class retroactively without paying for a deep fingerprint.
151 $this->recordProbe($extras, 'hosting_class', function () use ($platform) { return $platform->probeHostingClass(); }, array());
152
153 // Object-cache backend NAME, not just the on/off enum already
154 // shipped in `object_cache`. Detect Redis / Memcached / APCu
155 // / W3TC / LiteSpeed / WP Engine native via known constants
156 // + wp_using_ext_object_cache(). Stale-cache reports cluster
157 // by backend class.
158 $this->recordProbe($extras, 'object_cache_backend', function () use ($platform) { return $platform->probeObjectCacheBackend(); }, array());
159
160 // SHOW GLOBAL STATUS counterpart to mysql_globals. Captures
161 // runtime symptoms (lock waits, tmp-disk spills, aborted
162 // connects, slow queries) that the variables can only
163 // bound, never observe.
164 $this->recordProbe($extras, 'mysql_status', function () use ($db) { return $db->probeMysqlStatus(); }, array());
165
166 // DB charset + collation, plus per-column collation on the
167 // canonical JOIN keys for redirects (url, canonical_url) and
168 // logs_hits (requested_url). Collation drift silently
169 // disables index seeks on JOIN: symptom is "fast on staging,
170 // slow on prod with identical data."
171 $this->recordProbe($extras, 'db_collation', function () use ($db) { return $db->probeDbCollation(); }, array());
172
173 // WP + PHP timezone identity. Bruno-class sites in non-UTC
174 // zones (pt_BR, ja_JP) sometimes show off-by-N-hours bugs
175 // in cooldown arithmetic; capturing both lets us diff
176 // server time vs WP time vs PHP time after the fact.
177 $this->recordProbe($extras, 'timezone', function () use ($host) { return $host->probeTimezone(); }, array());
178
179 // Install + upgrade history. The single most useful
180 // bifurcator for "started after upgrade Tuesday" vs
181 // "always broken since install." Read-only from plugin
182 // options the upgrade path already writes.
183 $this->recordProbe($extras, 'plugin_lifecycle', function () use ($host) { return $host->probePluginLifecycle(); }, array());
184
185 // Top distinct recurring error signatures from the debug
186 // log file over the last 7 days, capped at 5 entries. The
187 // triggering error is captured by the report itself; this
188 // captures the recurring error which is often different
189 // and which the email-on-first-error path would never send.
190 $this->recordProbe($extras, 'recent_error_signatures', function () use ($debugLog) { return $debugLog->probeRecentErrorSignatures(); }, array());
191
192 // opcache detail beyond the on/off enum already shipped
193 // in `php_opcache_enabled`. validate_timestamps=0 +
194 // revalidate_freq high explains "fresh install still
195 // buggy after upgrade" reports where the host serves
196 // cached bytecode from the prior version.
197 $this->recordProbe($extras, 'opcache_settings', function () use ($host) { return $host->probeOpcacheSettings(); }, array());
198
199 // open_basedir restriction string (or null when not set).
200 // Hardened shared hosts use this to box file access;
201 // explains "permission denied" failures on paths the
202 // plugin can otherwise write.
203 $extras['open_basedir'] = $host->probeOpenBasedir();
204
205 // Multisite identity: is this the main site, what blog
206 // and network are we on, is the plugin network-activated?
207 // Behavior differs significantly across these axes
208 // (network-active vs single-site-active changes hook
209 // registration and upgrade scheduling).
210 $this->recordProbe($extras, 'multisite_role', function () use ($host) { return $host->probeMultisiteRole(); }, array());
211
212 // .htaccess writability at the WP home path. When false
213 // the plugin's Apache-rule install path cannot succeed
214 // and we fall back to the DB-only redirect handler.
215 // Differentiates "redirects not firing" reports between
216 // "Apache rule never wrote" and "DB handler bug".
217 $extras['htaccess_writable'] = $host->probeHtaccessWritable();
218
219 // /tmp filesystem free bytes. Some shared hosts have
220 // separate /tmp quotas from the WP install path; tmp
221 // exhaustion breaks MySQL tmp tables (Created_tmp_disk_*
222 // counter) and PHP file uploads. disk_free_bytes on the
223 // uploads dir cannot see this.
224 $this->recordProbe($extras, 'tmp_free_bytes', function () use ($host) { return $host->probeTmpFreeBytesOrThrow(); }, null);
225
226 if (function_exists('apply_filters')) {
227 $filtered = apply_filters('abj404_environment_extras', $extras);
228 if (is_array($filtered)) {
229 $extras = $filtered;
230 }
231 }
232
233 return $extras;
234 }
235
236 /**
237 * Run a probe and write either its return value into $extras[$key]
238 * on success, or a default value plus a marker $extras[$key.'_error']
239 * on failure. The marker is a short server-groupable slug
240 * ('sql_failed', 'wpdb_unavailable', 'fs_unavailable',
241 * 'invalid_shape', 'exception:<class>'), not the raw exception
242 * message. Exception text can carry PII (paths, user-supplied
243 * fragments) and we explicitly do not ship it. The raw message
244 * still goes to the plugin logger so local debugging is unaffected.
245 *
246 * Why marker keys at all: the prior pattern (tryMixedArray returning
247 * empty array) could not distinguish "probe succeeded with no data"
248 * from "probe failed and we have no signal." Markers make failure
249 * explicit so the server side does not have to guess.
250 *
251 * @param array<string,mixed> $extras
252 * @param string $key
253 * @param callable $fn
254 * @param mixed $default Value written to $extras[$key] on failure
255 * so downstream consumers can iterate without per-probe null
256 * checks.
257 * @return void
258 */
259 private function recordProbe(array &$extras, string $key, callable $fn, $default): void {
260 try {
261 $value = $fn();
262 } catch (\Throwable $e) {
263 $extras[$key] = $default;
264 $extras[$key . '_error'] = $this->classifyProbeError($e);
265 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackEnvironmentExtras probe "' . $key . '" failed: ' . $e->getMessage());
266 return;
267 }
268 $extras[$key] = $value;
269 }
270
271 /**
272 * Map a thrown probe exception to a short server-groupable slug.
273 * Matched on the message rather than the exception class because
274 * the probe helpers all throw \RuntimeException. The message is
275 * the differentiator. Unmatched throws degrade to
276 * 'exception:<ShortClass>' so the slug still carries fingerprint.
277 *
278 * @param \Throwable $e
279 * @return string
280 */
281 private function classifyProbeError(\Throwable $e): string {
282 $msg = strtolower((string)$e->getMessage());
283 if (strpos($msg, 'wpdb unavailable') !== false || strpos($msg, 'wpdb missing') !== false) {
284 return 'wpdb_unavailable';
285 }
286 if (strpos($msg, 'disk_free_space') !== false
287 || strpos($msg, 'disk_total_space') !== false
288 || strpos($msg, 'sys_get_temp_dir') !== false) {
289 return 'fs_unavailable';
290 }
291 if (strpos($msg, 'invalid shape') !== false
292 || strpos($msg, 'non-array') !== false
293 || strpos($msg, 'unexpected shape') !== false) {
294 return 'invalid_shape';
295 }
296 if (strpos($msg, 'sql') !== false
297 || strpos($msg, 'mysql') !== false
298 || strpos($msg, 'mariadb') !== false
299 || strpos($msg, 'query') !== false
300 || strpos($msg, 'processlist') !== false
301 || strpos($msg, 'simulated db') !== false
302 || strpos($msg, 'show global') !== false
303 || strpos($msg, 'show index') !== false
304 || strpos($msg, 'show processlist') !== false
305 || strpos($msg, 'information_schema') !== false
306 || strpos($msg, 'all tables failed') !== false
307 || strpos($msg, 'no tables probed') !== false) {
308 return 'sql_failed';
309 }
310 $shortClass = (new \ReflectionClass($e))->getShortName();
311 return 'exception:' . $shortClass;
312 }
313 }
314