PluginProbe
404 Solution / trunk
404 Solution vtrunk
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 trunk, at includes/feedback/FeedbackEnvironmentExtras.php

376 lines 19.4 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__ . '/MysqlServerStateProbe.php';
8 require_once __DIR__ . '/PluginSchemaMetadataProbe.php';
9 require_once __DIR__ . '/RollupFreshnessProbe.php';
10 require_once __DIR__ . '/FeedbackEnvironmentExtras_HostProbes.php';
11 require_once __DIR__ . '/FeedbackEnvironmentExtras_PlatformFingerprint.php';
12 require_once __DIR__ . '/FeedbackEnvironmentExtras_CacheFingerprint.php';
13 require_once __DIR__ . '/FeedbackEnvironmentExtras_DebugLogSignatures.php';
14 require_once __DIR__ . '/FeedbackTransportLog.php';
15 require_once dirname(__DIR__) . '/services/PostResponseWorkerBudget.php';
16
17 /**
18 * Environment-extras passthrough probes for the feedback payload's JSON column.
19 *
20 * Orchestrates a set of best-effort diagnostic probes about the server
21 * environment (MySQL globals, disk headroom, PHP SAPI, hosting class,
22 * etc.) and packages them into a keyed array for the `environment_extras`
23 * field of the feedback payload.
24 *
25 * This class owns ONLY the probe registry and the failure-isolation
26 * wrapper. The probe implementations live in six collaborator classes,
27 * partitioned by the subject each one observes:
28 * - MysqlServerStateProbe: the database server's own state via $wpdb
29 * (SHOW GLOBAL/SESSION VARIABLES, SHOW GLOBAL STATUS, SHOW PROCESSLIST).
30 * No plugin table is named in it.
31 * - PluginSchemaMetadataProbe: this plugin's own storage shape via
32 * information_schema and SHOW INDEX (table sizes, index cardinality,
33 * JOIN-hot column collations).
34 * - RollupFreshnessProbe: redirects-hits rollup staleness, read live
35 * through the logs_repository service (no SQL of its own).
36 * - FeedbackEnvironmentExtras_HostProbes: dynamic PHP/OS/WP runtime
37 * state (opcache, filesystem headroom, open_basedir, timezone,
38 * multisite role, htaccess writability, lifecycle).
39 * - FeedbackEnvironmentExtras_PlatformFingerprint: static platform
40 * identity (hosting class, control panel, PHP execution stack) --
41 * marker-table scans that rarely change for the life of the install.
42 * - FeedbackEnvironmentExtras_CacheFingerprint: which cache implementation
43 * owns the request caches -- who INSTALLED it (drop-in headers) and what
44 * is actually RUNNING (constants, classes, extensions), which disagree on
45 * a site that has switched caching plugins.
46 * - FeedbackEnvironmentExtras_DebugLogSignatures: tail-read of the
47 * plugin debug log + PII-stripping signature normalization for
48 * `recent_error_signatures`.
49 *
50 * Each probe is wrapped by recordProbe() so a single probe failure
51 * cannot blank the others or block the support send. Failures emit a
52 * marker key `<probe>_error` with a short slug so the server side can
53 * tell "no data" from "probe failed."
54 *
55 * Used by ABJ_404_Solution_FeedbackTransport via composition:
56 * $extras = (new ABJ_404_Solution_FeedbackEnvironmentExtras())->collect();
57 *
58 * The probe set is documented in detail in
59 * docs/bruno-failure-modes-2026-05-13.md (server-side correlation
60 * targets) and pinned by tests/FeedbackTransportEnvironmentExtrasTest.
61 */
62 class ABJ_404_Solution_FeedbackEnvironmentExtras {
63
64 /** @var ABJ_404_Solution_MysqlServerStateProbe */
65 private $server;
66
67 /** @var ABJ_404_Solution_PluginSchemaMetadataProbe */
68 private $schema;
69
70 /** @var ABJ_404_Solution_RollupFreshnessProbe */
71 private $rollup;
72
73 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_HostProbes */
74 private $host;
75
76 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_PlatformFingerprint */
77 private $platform;
78
79 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_CacheFingerprint */
80 private $cache;
81
82 /** @var ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures */
83 private $debugLog;
84
85 public function __construct() {
86 $this->server = new ABJ_404_Solution_MysqlServerStateProbe();
87 $this->schema = new ABJ_404_Solution_PluginSchemaMetadataProbe();
88 $this->rollup = new ABJ_404_Solution_RollupFreshnessProbe();
89 $this->host = new ABJ_404_Solution_FeedbackEnvironmentExtras_HostProbes();
90 $this->platform = new ABJ_404_Solution_FeedbackEnvironmentExtras_PlatformFingerprint();
91 $this->cache = new ABJ_404_Solution_FeedbackEnvironmentExtras_CacheFingerprint();
92 $this->debugLog = new ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures();
93 }
94
95 /**
96 * Best-effort diagnostic passthrough for the server's JSON column. The
97 * typed columns cover plugin version + WP/PHP/DB identity + content
98 * counts, but they cannot cover the operational signals that decide
99 * whether a query times out on a real shared host: MySQL memory globals
100 * (innodb_buffer_pool_size, tmp_table_size), disk headroom,
101 * and PHP SAPI specifics that the server doesn't pre-declare.
102 *
103 * Every probe is wrapped in recordProbe() so a failed lookup never
104 * blocks the support send and surfaces a `<probe>_error` marker
105 * with a short server-groupable slug. Filterable via the
106 * `abj404_environment_extras` filter so operators can append
107 * site-specific diagnostics (or strip fields for privacy) before the
108 * payload is sent.
109 *
110 * @return array<string, mixed>
111 */
112 public function collect(): array {
113 $extras = array();
114 $server = $this->server;
115 $schema = $this->schema;
116 $rollup = $this->rollup;
117 $host = $this->host;
118 $platform = $this->platform;
119 $cache = $this->cache;
120 $debugLog = $this->debugLog;
121
122 // MySQL global variables: the binding constraints for slow
123 // JOIN / GROUP BY on Bruno-class sites. SHOW GLOBAL VARIABLES
124 // is read-only, no plugin tables involved.
125 $this->recordProbe($extras, 'mysql_globals', function () use ($server) { return $server->collectMysqlGlobals(); }, array());
126
127 // Live SHOW SESSION VARIABLES probe for this connection. Some hosts
128 // override operational variables per-session (poolers, connection
129 // init hooks), so this can diverge from mysql_globals.
130 $this->recordProbe($extras, 'mysql_session_probe', function () use ($server) { return $server->collectMysqlSessionVariables(); }, array());
131
132 // Disk headroom on the WP uploads directory (where the plugin's
133 // debug log and any cron-scratch files land). "Table is full"
134 // errors are nearly always disk-quota, not the logical
135 // table-full condition.
136 $this->recordProbe($extras, 'disk_free_bytes', function () use ($host) { return $host->diskFreeBytesOrThrow(); }, null);
137 $this->recordProbe($extras, 'disk_total_bytes', function () use ($host) { return $host->diskTotalBytesOrThrow(); }, null);
138
139 // PHP runtime identity beyond version. SAPI distinguishes
140 // mod_php (per-request fork, fresh memory) from php-fpm
141 // (long-lived worker, opcache hot). max_input_vars caps how
142 // many POST fields the importer can accept. realpath_cache
143 // size matters for sites with many include paths. None of it can
144 // fail, so it is merged whole rather than registered as a probe;
145 // the reasoning for each field lives with the values in
146 // FeedbackEnvironmentExtras_HostProbes::collectPhpRuntimeIdentity().
147 $extras = array_merge($extras, $host->collectPhpRuntimeIdentity());
148
149 // Plugin table sizes beyond logsv2 (which has its own typed
150 // column). redirects volume and logs_hits rollup size are
151 // direct signals for the getRedirectsForViewTempTable.sql
152 // perf class.
153 $this->recordProbe($extras, 'plugin_tables_bytes', function () use ($schema) { return $schema->collectPluginTableSizes(); }, array());
154
155 // Redirects-hits rollup freshness signals: does the rollup table
156 // exist, does it need a rebuild right now, when did it last
157 // refresh/get scheduled, and how far behind is its watermark
158 // relative to logsv2? Read live from the rollup service, the
159 // subsystem most implicated in "the admin redirects page never
160 // loads" reports.
161 $this->recordProbe($extras, 'view_build_state', function () use ($rollup) { return $rollup->collectRollupFreshness(); }, array());
162
163 // SHOW PROCESSLIST row count. Indicator of shared-host MySQL
164 // saturation: a queue of 200+ idle connections explains why
165 // the staged build's BEGIN/COMMIT slots wait. Just the count;
166 // no connection details (user/host) are emitted.
167 $this->recordProbe($extras, 'active_connection_count', function () use ($server) { return $server->probeActiveConnectionCount(); }, null);
168
169 // SHOW INDEX cardinality for the canonical indexes on
170 // redirects + logs_hits + logs_hits_preagg. A degraded
171 // cardinality (1 row, or NULL after a crash recovery) is a
172 // sufficient explanation for a previously-fast JOIN suddenly
173 // doing a full table scan. Shape: {table: {index: int}}.
174 $this->recordProbe($extras, 'index_cardinality', function () use ($schema) { return $schema->probeIndexCardinality(); }, array());
175
176 // Best-effort hosting-class hint parsed from server_software
177 // and host-specific environment markers (cPanel, hPanel,
178 // Plesk, WP Engine, Kinsta, Pantheon, Flywheel, RunCloud,
179 // CloudPanel). Lets server-side group heartbeats by host
180 // class retroactively without paying for a deep fingerprint.
181 // The database server's version SUFFIX is one of the three independent
182 // markers that name Azure App Service, whose FPM pools can be built
183 // with clear_env and publish no environment variable at all. Supplied
184 // from here because $wpdb access belongs to the server-state probe, not
185 // to the platform fingerprint (see that class's INFRASTRUCTURE_HOST_MARKERS).
186 $this->recordProbe($extras, 'hosting_class', function () use ($platform, $server) {
187 return $platform->probeHostingClass(array(
188 'php_sapi' => PHP_SAPI,
189 'db_server_version' => $server->serverVersionString(),
190 ));
191 }, array());
192
193 // Full-request page-cache drop-ins can own an outer output buffer.
194 // Report only presence and the declared Plugin Name so support can
195 // identify that foreign owner without receiving file content or paths.
196 $this->recordProbe($extras, 'advanced_cache_dropin', function () use ($cache) {
197 return $cache->probeCacheDropin('advanced_cache');
198 }, array('present' => false, 'owner' => ''));
199
200 // Object-cache backend NAME, not just the on/off enum already
201 // shipped in `object_cache`. Detect Redis / Memcached / APCu
202 // / W3TC / LiteSpeed / WP Engine native via known constants
203 // + wp_using_ext_object_cache(). Stale-cache reports cluster
204 // by backend class.
205 $this->recordProbe($extras, 'object_cache_backend', function () use ($cache) { return $cache->probeObjectCacheBackend(); }, array());
206
207 // The backend marker identifies the running cache implementation;
208 // the drop-in header identifies which plugin installed object-cache.php.
209 $this->recordProbe($extras, 'object_cache_dropin', function () use ($cache) {
210 return $cache->probeCacheDropin('object_cache');
211 }, array('present' => false, 'owner' => ''));
212
213 // SHOW GLOBAL STATUS counterpart to mysql_globals. Captures
214 // runtime symptoms (lock waits, tmp-disk spills, aborted
215 // connects, slow queries) that the variables can only
216 // bound, never observe.
217 $this->recordProbe($extras, 'mysql_status', function () use ($server) { return $server->probeMysqlStatus(); }, array());
218
219 // DB charset + collation, plus per-column collation on the
220 // canonical JOIN keys for redirects (url, canonical_url) and
221 // logs_hits (requested_url). Collation drift silently
222 // disables index seeks on JOIN: symptom is "fast on staging,
223 // slow on prod with identical data."
224 $this->recordProbe($extras, 'db_collation', function () use ($schema) { return $schema->probeDbCollation(); }, array());
225
226 // WP + PHP timezone identity. Bruno-class sites in non-UTC
227 // zones (pt_BR, ja_JP) sometimes show off-by-N-hours bugs
228 // in cooldown arithmetic; capturing both lets us diff
229 // server time vs WP time vs PHP time after the fact.
230 $this->recordProbe($extras, 'timezone', function () use ($host) { return $host->probeTimezone(); }, array());
231
232 // Install + upgrade history. The single most useful
233 // bifurcator for "started after upgrade Tuesday" vs
234 // "always broken since install." Read-only from plugin
235 // options the upgrade path already writes.
236 $this->recordProbe($extras, 'plugin_lifecycle', function () use ($host) { return $host->probePluginLifecycle(); }, array());
237
238 // Top distinct recurring error signatures from the debug
239 // log file over the last 7 days, capped at 5 entries. The
240 // triggering error is captured by the report itself; this
241 // captures the recurring error which is often different
242 // and which the email-on-first-error path would never send.
243 $this->recordProbe($extras, 'recent_error_signatures', function () use ($debugLog) { return $debugLog->probeRecentErrorSignatures(); }, array());
244
245 // opcache detail beyond the on/off enum already shipped
246 // in `php_opcache_enabled`. validate_timestamps=0 +
247 // revalidate_freq high explains "fresh install still
248 // buggy after upgrade" reports where the host serves
249 // cached bytecode from the prior version.
250 $this->recordProbe($extras, 'opcache_settings', function () use ($host) { return $host->probeOpcacheSettings(); }, array());
251
252 // open_basedir restriction string (or null when not set).
253 // Hardened shared hosts use this to box file access;
254 // explains "permission denied" failures on paths the
255 // plugin can otherwise write.
256 $extras['open_basedir'] = $host->probeOpenBasedir();
257
258 // Multisite identity: is this the main site, what blog
259 // and network are we on, is the plugin network-activated?
260 // Behavior differs significantly across these axes
261 // (network-active vs single-site-active changes hook
262 // registration and upgrade scheduling).
263 $this->recordProbe($extras, 'multisite_role', function () use ($host) { return $host->probeMultisiteRole(); }, array());
264
265 // .htaccess writability at the WP home path. When false
266 // the plugin's Apache-rule install path cannot succeed
267 // and we fall back to the DB-only redirect handler.
268 // Differentiates "redirects not firing" reports between
269 // "Apache rule never wrote" and "DB handler bug".
270 $extras['htaccess_writable'] = $host->probeHtaccessWritable();
271
272 // /tmp filesystem free bytes. Some shared hosts have
273 // separate /tmp quotas from the WP install path; tmp
274 // exhaustion breaks MySQL tmp tables (Created_tmp_disk_*
275 // counter) and PHP file uploads. disk_free_bytes on the
276 // uploads dir cannot see this.
277 $this->recordProbe($extras, 'tmp_free_bytes', function () use ($host) { return $host->probeTmpFreeBytesOrThrow(); }, null);
278
279 if (function_exists('apply_filters')) {
280 $filtered = apply_filters('abj404_environment_extras', $extras);
281 if (is_array($filtered)) {
282 $extras = $filtered;
283 }
284 }
285
286 return $extras;
287 }
288
289 /**
290 * Run a probe and write either its return value into $extras[$key]
291 * on success, or a default value plus a marker $extras[$key.'_error']
292 * on failure. The marker is a short server-groupable slug
293 * ('sql_failed', 'wpdb_unavailable', 'fs_unavailable',
294 * 'invalid_shape', 'exception:<class>'), not the raw exception
295 * message. Exception text can carry PII (paths, user-supplied
296 * fragments) and we explicitly do not ship it. The raw message
297 * still goes to the plugin logger so local debugging is unaffected.
298 *
299 * Why marker keys at all: the prior pattern (tryMixedArray returning
300 * empty array) could not distinguish "probe succeeded with no data"
301 * from "probe failed and we have no signal." Markers make failure
302 * explicit so the server side does not have to guess.
303 *
304 * @param array<string,mixed> $extras
305 * @param string $key
306 * @param callable $fn
307 * @param mixed $default Value written to $extras[$key] on failure
308 * so downstream consumers can iterate without per-probe null
309 * checks.
310 * @return void
311 */
312 private function recordProbe(array &$extras, string $key, callable $fn, $default): void {
313 try {
314 $value = $fn();
315 } catch (\Throwable $e) {
316 $extras[$key] = $default;
317 $extras[$key . '_error'] = $this->classifyProbeError($e);
318 ABJ_404_Solution_FeedbackTransportLog::log('warn', 'FeedbackEnvironmentExtras probe "' . $key . '" failed: ' . $e->getMessage());
319 return;
320 }
321 $extras[$key] = $value;
322 }
323
324 /**
325 * Probe-failure slug => the lowercased message substrings that select it.
326 * FIRST MATCH WINS, so declaration order is the precedence order: the
327 * narrow, unambiguous causes are listed before `sql_failed`, whose needles
328 * ('sql', 'query') are broad enough to swallow a more specific message.
329 *
330 * A table rather than an if/elseif chain because this is a classifier with
331 * five branches and grows by one every time a probe learns a new way to
332 * fail; adding a cause should be adding a row, not adding a branch.
333 * `service_unavailable` is one such row (t_260801_071502_922): a probe
334 * whose own collaborator service could not be resolved is a plugin-wiring
335 * failure, not an environment failure, and the two must be groupable apart
336 * on the server, because the wiring class is exactly what let
337 * `view_build_state` ship empty for seven weeks without anyone noticing.
338 *
339 * @var array<string, array<int, string>>
340 */
341 private const PROBE_ERROR_SIGNATURES = array(
342 'wpdb_unavailable' => array('wpdb unavailable', 'wpdb missing'),
343 'fs_unavailable' => array('disk_free_space', 'disk_total_space', 'sys_get_temp_dir'),
344 'service_unavailable' => array('service unavailable'),
345 'invalid_shape' => array('invalid shape', 'non-array', 'unexpected shape'),
346 'sql_failed' => array(
347 'sql', 'mysql', 'mariadb', 'query', 'processlist', 'simulated db',
348 'show global', 'show index', 'show processlist', 'information_schema',
349 'all tables failed', 'no tables probed',
350 ),
351 );
352
353 /**
354 * Map a thrown probe exception to a short server-groupable slug.
355 * Matched on the message rather than the exception class because
356 * the probe helpers all throw \RuntimeException. The message is
357 * the differentiator. Unmatched throws degrade to
358 * 'exception:<ShortClass>' so the slug still carries fingerprint.
359 *
360 * @param \Throwable $e
361 * @return string
362 */
363 private function classifyProbeError(\Throwable $e): string {
364 $msg = strtolower((string)$e->getMessage());
365 foreach (self::PROBE_ERROR_SIGNATURES as $slug => $needles) {
366 foreach ($needles as $needle) {
367 if (strpos($msg, $needle) !== false) {
368 return $slug;
369 }
370 }
371 }
372 $shortClass = (new \ReflectionClass($e))->getShortName();
373 return 'exception:' . $shortClass;
374 }
375 }
376