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_DebugLogSignatures.php

FeedbackEnvironmentExtras_DebugLogSignatures.php in 404 Solution trunk, at includes/feedback/FeedbackEnvironmentExtras_DebugLogSignatures.php

287 lines 13.9 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 * Debug-log tail scanner + PII-stripping signature normalizer for the
9 * feedback payload's `recent_error_signatures` field.
10 *
11 * Two responsibilities both anchored to the debug log:
12 * 1. probeRecentErrorSignatures(): tail-read the plugin debug log
13 * (capped at 256 KB), parse lines matching the canonical
14 * "YYYY-MM-DD HH:MM:SS [TZ] (LEVEL): ..." shape that Logging.php emits,
15 * keep only [ERROR]/[WARN] entries within the last 7 days, group
16 * by coarse signature, return the top 5 by count.
17 * 2. normalizeErrorSignature(): the PII-stripping transform that
18 * makes the grouping correct. Cuts the per-request/environment
19 * envelope the plugin's own writer appends (referrer, requested
20 * URL, versions, query duration), folds body-embedded wall-clock
21 * stamps, strips absolute paths to basenames, collapses memory
22 * addresses, hex literals, multi-digit numbers, and whitespace runs
23 * so different incident timestamps and addresses fold into the same
24 * signature key. This is the unit pinned by
25 * tests/F6UrlFragmentLeakIntoErrorSignatureTest, and the grouping
26 * POLICY it implements is pinned against the shared corpus in
27 * tests/ErrorSignatureGroupingCorpusTest -- shared because the
28 * report server implements the same policy a second time, in JS
29 * (404-solution-server src/lib/fingerprint.js).
30 *
31 * Owned by ABJ_404_Solution_FeedbackEnvironmentExtras via composition;
32 * see that class's collect() method for the recordProbe() wrapper that
33 * converts a thrown scan failure into a recent_error_signatures_error
34 * marker slug.
35 */
36 class ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures {
37
38 /**
39 * Severity words that must never be consumed as a timezone abbreviation
40 * while folding an embedded wall-clock stamp.
41 */
42 const LOG_LEVEL_WORDS = 'ERROR|FATAL|WARN|WARNING|INFO|DEBUG|NOTICE|TRACE';
43
44 /**
45 * The per-request/environment tail the plugin's own writer appends, which
46 * is not part of the defect. Two anchors, both requiring the ", <field>:"
47 * shape so prose that merely mentions the words cannot trigger a cut:
48 *
49 * ", PHP version: X, WP ver: ..." -- ABJ_404_Solution_LoggingMessageWriter
50 * ::writeErrorMessage() appends this to EVERY error line, and the
51 * "Referrer:" / "Requested URL:" fields inside it change on every hit.
52 * The anchor spans two adjacent fields, so a message whose own body says
53 * "PHP version: 8.3.33 is below the minimum" keeps it.
54 *
55 * ", Execution time: ..." / ", execution_time: ..." --
56 * ABJ_404_Solution_DatabaseSqlErrorReporter puts the query duration
57 * there, followed only by DB ver, the server-variable dump and the
58 * stripped query. Everything identifying the failure (the driver error,
59 * the SQL file, the route) sits before it.
60 *
61 * Every value cut here already travels in its own payload field, so no
62 * triage information is lost -- and the referrer/requested URL leave the
63 * telemetry payload entirely, which shrinks its PII surface.
64 */
65 const REQUEST_ENVELOPE_PATTERN =
66 '/,\s*(?:execution[ _]time\s*:|PHP version\s*:\s*\S+\s*,\s*WP ver\s*:).*$/is';
67
68 /**
69 * A wall-clock stamp embedded in the message BODY, in any zone form the
70 * plugin or the host can emit (named abbreviation, abbreviation plus
71 * offset, bare offset, ISO "Z", or none). At most ONE zone token is
72 * consumed and it may not be a severity word, so
73 * "... 03:20:01 ERROR ..." cannot lose its level.
74 *
75 * The leading log stamp never reaches here (probeRecentErrorSignatures()
76 * consumes it as capture group 1), but a body can carry its own: see
77 * ABJ_404_Solution_OldPermalinkPostResolver, which warns with the offending
78 * post_date interpolated. The pre-existing "\d{4,}" fold only collapsed
79 * such a value's YEAR ("2026-08-17 03:22:41" -> "N-08-17 03:22:41"), which
80 * left a per-occurrence splitter behind.
81 */
82 const EMBEDDED_DATETIME_PATTERN =
83 '/\b\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?'
84 . '(?:Z\b|\s+(?:(?!(?:' . self::LOG_LEVEL_WORDS . ')\b)[A-Z]{2,5}(?:[+-]\d{2}:?\d{2})?'
85 . '|[+-]\d{2}(?::?\d{2})?))?/';
86
87 /**
88 * Top distinct recurring error signatures from the plugin's debug
89 * log file over the last 7 days, capped at 5 entries. The triggering
90 * error is captured by the report itself ('error_signature' on the
91 * payload); this probe captures the recurring error which is often
92 * different and would never reach the email-on-first-error path.
93 *
94 * Bounded cost: reads the tail 256 KB of the debug file, parses
95 * lines matching the canonical "YYYY-MM-DD HH:MM:SS [TZ] (LEVEL): ..."
96 * shape, keeps only [ERROR]/[WARN] entries within the last 7 days,
97 * groups by a coarse signature (first 200 chars after the level),
98 * keeps the top 5 by count. Returns an empty array on any read
99 * failure.
100 *
101 * Shape:
102 * [ {signature: string, count: int, last_seen_at: int}, ... ]
103 *
104 * An absent, unreadable, or error-free log yields an empty array: on a
105 * healthy site that is the truthful answer. A MISSING LOGGING SERVICE
106 * throws instead, so the caller's recordProbe() wrapper writes a
107 * `recent_error_signatures_error` marker. Those two cases used to be
108 * indistinguishable in the payload -- the same "silently degrade to empty
109 * after the thing I observe is refactored away" shape that left
110 * `view_build_state` looking healthy-but-empty for seven weeks
111 * (t_260801_071502_922). A probe that cannot look must never report
112 * "nothing to see".
113 *
114 * @return array<int, array<string, mixed>>
115 */
116 public function probeRecentErrorSignatures(): array {
117 $out = array();
118 $log = function_exists('abj_service_optional') ? abj_service_optional('logging') : null;
119 if (!is_object($log) || !method_exists($log, 'getDebugFilePath')) {
120 throw new \RuntimeException('logging service unavailable for recent_error_signatures probe');
121 }
122 $path = (string)$log->getDebugFilePath();
123 if ($path === '' || !is_file($path) || !is_readable($path)) {
124 return $out;
125 }
126 $size = @filesize($path);
127 if ($size === false || $size === 0) {
128 return $out;
129 }
130 $readBytes = 262144; // 256 KB
131 $offset = $size > $readBytes ? $size - $readBytes : 0;
132 $fh = @fopen($path, 'rb');
133 if (!is_resource($fh)) {
134 return $out;
135 }
136 $tail = '';
137 try {
138 if ($offset > 0) {
139 @fseek($fh, $offset);
140 // Discard the partial first line so we only group on whole records.
141 @fgets($fh);
142 }
143 $chunk = @fread($fh, $readBytes);
144 if (is_string($chunk)) {
145 $tail = $chunk;
146 }
147 } finally {
148 @fclose($fh);
149 }
150 if ($tail === '') {
151 return $out;
152 }
153 $cutoff = abj_clock()->now() - 7 * 86400;
154 $byKey = array();
155 $lines = preg_split('/\r?\n/', $tail);
156 if (!is_array($lines)) {
157 return $out;
158 }
159 foreach ($lines as $line) {
160 if (!is_string($line) || $line === '') { continue; }
161 // Match "YYYY-MM-DD HH:MM:SS [TZ] (LEVEL): tail..." per
162 // LogTimestampFormatter's actual 'Y-m-d H:i:s T' output (the
163 // trailing timezone abbreviation/offset is optional in the
164 // pattern so older or hand-edited log lines without one still
165 // match).
166 if (!preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})(?:\s+\S+)?\s+\((ERROR|WARN)\):\s*(.*)$/', $line, $m)) {
167 continue;
168 }
169 // The captured datetime is in the WP site's configured timezone
170 // (LogTimestampFormatter's contract), not PHP's default timezone,
171 // so it must be interpreted the same way here before comparing
172 // against the true-UTC $cutoff -- otherwise a non-UTC site's
173 // recent errors are silently miscounted as too old (or too new).
174 try {
175 $ts = (new DateTimeImmutable($m[1], ABJ_404_Solution_SiteTimezone::resolve()))->getTimestamp();
176 } catch (Exception $e) {
177 // The regex above validates digit *shape* (\d{4}-\d{2}-\d{2}
178 // \d{2}:\d{2}:\d{2}) but not calendar validity, so a
179 // corrupted or hand-edited log line (e.g. month 13) can still
180 // reach here. Rare, but a run of these would indicate log
181 // corruption or a LogTimestampFormatter regression, so make
182 // it visible in diagnostics instead of dropping it with zero
183 // trace. Delegated to a helper (rather than inlined here) to
184 // keep this already-complex parsing loop's branch count from
185 // growing further.
186 $this->logUnparseableTimestamp($log, $m[1], $e);
187 continue;
188 }
189 if ($ts < $cutoff) { continue; }
190 $level = $m[2];
191 $msg = trim($m[3]);
192 if ($msg === '') { continue; }
193 $sig = $level . ':' . substr($this->normalizeErrorSignature($msg), 0, 200);
194 if (!isset($byKey[$sig])) {
195 $byKey[$sig] = array('signature' => $sig, 'count' => 0, 'last_seen_at' => 0);
196 }
197 $byKey[$sig]['count']++;
198 if ($ts > $byKey[$sig]['last_seen_at']) {
199 $byKey[$sig]['last_seen_at'] = $ts;
200 }
201 }
202 if (empty($byKey)) {
203 return $out;
204 }
205 $list = array_values($byKey);
206 usort($list, function ($a, $b) {
207 $cmp = $b['count'] - $a['count'];
208 if ($cmp !== 0) { return $cmp; }
209 return $b['last_seen_at'] - $a['last_seen_at'];
210 });
211 return array_slice($list, 0, 5);
212 }
213
214 /**
215 * Report a log line whose timestamp matched the digit-shape regex but
216 * failed to parse as a real calendar date/time (e.g. month 13). Debug
217 * mode gated (via Logging::debugMessage()'s own contract) since this can
218 * run once per matched line in the tail and a corrupted log could
219 * otherwise flood the debug log on every probe call.
220 *
221 * @param object $log Already validated as an object with getDebugFilePath()
222 * by the caller; debugMessage() itself is checked here
223 * since not every logger double implements it.
224 * @param string $rawTimestamp The unparseable captured group, for context.
225 * @param \Exception $e
226 * @return void
227 */
228 private function logUnparseableTimestamp($log, string $rawTimestamp, \Exception $e): void {
229 if (method_exists($log, 'debugMessage')) {
230 $log->debugMessage('FeedbackEnvironmentExtras_DebugLogSignatures: unparseable log timestamp "' .
231 $rawTimestamp . '": ' . $e->getMessage());
232 }
233 }
234
235 /**
236 * Coarse-grain an error message so different incident timestamps,
237 * memory addresses, file paths, and line numbers fold into the same
238 * signature. Used by probeRecentErrorSignatures to group recurring
239 * errors. Exposed (public) so the unit test
240 * tests/F6UrlFragmentLeakIntoErrorSignatureTest can pin the
241 * PII-stripping behavior directly.
242 *
243 * Mirrors ABJ_404_Solution_CrashBeacon::lightRedact() (the cheap
244 * capture-time version run inside the fatal handler) and is also run
245 * again on an already-lightRedact'd message at report time
246 * (CrashBeaconReporter::buildSignature()), so the digit fold here must
247 * use the same "bytes" exemption or the capture-time fix has no effect
248 * on the final reported text. See lightRedact()'s docblock: a byte
249 * count is never PII and is the memory_limit/allocation-size
250 * diagnostic the crash-beacon feature exists to report. It is also
251 * stable per site (memory_limit does not change between requests), so
252 * exempting it does not hurt the grouping this function exists for.
253 *
254 * DELIBERATELY not normalized, and each one is a divergence from the
255 * server-side twin (404-solution-server src/lib/fingerprint.js) that the
256 * shared corpus records rather than tries to reconcile:
257 * - Runs of 4 or 5 digits stay folded to "N" (the server keeps them and
258 * folds only 6+). This output ships as readable text in the telemetry
259 * payload, so folding post / redirect / user ids is a PII control, not
260 * a grouping choice, and loosening it would make previously redacted
261 * values visible.
262 * - Source line numbers. The only carrier here is the crash beacon,
263 * which already reports file and line as their own fields.
264 *
265 * @param string $msg
266 * @return string
267 */
268 public function normalizeErrorSignature(string $msg): string {
269 $s = $msg;
270 // Cut the per-request/environment envelope first: it is the largest
271 // and most variable part, and removing it also keeps the caller's
272 // 200-char signature window for message text that discriminates.
273 $s = preg_replace(self::REQUEST_ENVELOPE_PATTERN, '', $s) ?? $s;
274 // Fold body-embedded wall-clock stamps before the digit fold below,
275 // which would otherwise eat the year and leave the rest standing.
276 $s = preg_replace(self::EMBEDDED_DATETIME_PATTERN, '[TS]', $s) ?? $s;
277 // Strip absolute paths to just the basename.
278 $s = preg_replace('#/[A-Za-z0-9_\-\./]+/([A-Za-z0-9_\-]+\.php)#', '$1', $s) ?? $s;
279 // Collapse memory addresses, hex, and digit sequences.
280 $s = preg_replace('/\b0x[0-9a-fA-F]+\b/', '0xN', $s) ?? $s;
281 $s = preg_replace('/\b\d{4,}\b(?!\s*bytes\b)/i', 'N', $s) ?? $s;
282 // Collapse runs of whitespace.
283 $s = preg_replace('/\s+/', ' ', $s) ?? $s;
284 return trim($s);
285 }
286 }
287