| 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 (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. Strips absolute paths to basenames, |
| 19 |
* collapses memory addresses, hex literals, multi-digit numbers, |
| 20 |
* and whitespace runs so different incident timestamps and |
| 21 |
* addresses fold into the same signature key. This is the unit |
| 22 |
* pinned by tests/F6UrlFragmentLeakIntoErrorSignatureTest. |
| 23 |
* |
| 24 |
* Owned by ABJ_404_Solution_FeedbackEnvironmentExtras via composition; |
| 25 |
* see that class's collect() method for the recordProbe() wrapper that |
| 26 |
* converts a thrown scan failure into a recent_error_signatures_error |
| 27 |
* marker slug. |
| 28 |
*/ |
| 29 |
class ABJ_404_Solution_FeedbackEnvironmentExtras_DebugLogSignatures { |
| 30 |
|
| 31 |
/** |
| 32 |
* Top distinct recurring error signatures from the plugin's debug |
| 33 |
* log file over the last 7 days, capped at 5 entries. The triggering |
| 34 |
* error is captured by the report itself ('error_signature' on the |
| 35 |
* payload); this probe captures the recurring error which is often |
| 36 |
* different and would never reach the email-on-first-error path. |
| 37 |
* |
| 38 |
* Bounded cost: reads the tail 256 KB of the debug file, parses |
| 39 |
* lines matching the canonical "YYYY-MM-DD HH:MM:SS (LEVEL): ..." |
| 40 |
* shape, keeps only [ERROR]/[WARN] entries within the last 7 days, |
| 41 |
* groups by a coarse signature (first 200 chars after the level), |
| 42 |
* keeps the top 5 by count. Returns an empty array on any read |
| 43 |
* failure. |
| 44 |
* |
| 45 |
* Shape: |
| 46 |
* [ {signature: string, count: int, last_seen_at: int}, ... ] |
| 47 |
* |
| 48 |
* @return array<int, array<string, mixed>> |
| 49 |
*/ |
| 50 |
public function probeRecentErrorSignatures(): array { |
| 51 |
$out = array(); |
| 52 |
$log = function_exists('abj_service_optional') ? abj_service_optional('logging') : null; |
| 53 |
if (!is_object($log) || !method_exists($log, 'getDebugFilePath')) { |
| 54 |
return $out; |
| 55 |
} |
| 56 |
$path = (string)$log->getDebugFilePath(); |
| 57 |
if ($path === '' || !is_file($path) || !is_readable($path)) { |
| 58 |
return $out; |
| 59 |
} |
| 60 |
$size = @filesize($path); |
| 61 |
if ($size === false || $size === 0) { |
| 62 |
return $out; |
| 63 |
} |
| 64 |
$readBytes = 262144; // 256 KB |
| 65 |
$offset = $size > $readBytes ? $size - $readBytes : 0; |
| 66 |
$fh = @fopen($path, 'rb'); |
| 67 |
if (!is_resource($fh)) { |
| 68 |
return $out; |
| 69 |
} |
| 70 |
$tail = ''; |
| 71 |
try { |
| 72 |
if ($offset > 0) { |
| 73 |
@fseek($fh, $offset); |
| 74 |
// Discard the partial first line so we only group on whole records. |
| 75 |
@fgets($fh); |
| 76 |
} |
| 77 |
$chunk = @fread($fh, $readBytes); |
| 78 |
if (is_string($chunk)) { |
| 79 |
$tail = $chunk; |
| 80 |
} |
| 81 |
} finally { |
| 82 |
@fclose($fh); |
| 83 |
} |
| 84 |
if ($tail === '') { |
| 85 |
return $out; |
| 86 |
} |
| 87 |
$cutoff = abj_clock()->now() - 7 * 86400; |
| 88 |
$byKey = array(); |
| 89 |
$lines = preg_split('/\r?\n/', $tail); |
| 90 |
if (!is_array($lines)) { |
| 91 |
return $out; |
| 92 |
} |
| 93 |
foreach ($lines as $line) { |
| 94 |
if (!is_string($line) || $line === '') { continue; } |
| 95 |
// Match "YYYY-MM-DD HH:MM:SS (LEVEL): tail..." per Logging.php format. |
| 96 |
if (!preg_match('/^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}) \((ERROR|WARN)\):\s*(.*)$/', $line, $m)) { |
| 97 |
continue; |
| 98 |
} |
| 99 |
$ts = strtotime($m[1]); |
| 100 |
if ($ts === false || $ts < $cutoff) { continue; } |
| 101 |
$level = $m[2]; |
| 102 |
$msg = trim($m[3]); |
| 103 |
if ($msg === '') { continue; } |
| 104 |
$sig = $level . ':' . substr($this->normalizeErrorSignature($msg), 0, 200); |
| 105 |
if (!isset($byKey[$sig])) { |
| 106 |
$byKey[$sig] = array('signature' => $sig, 'count' => 0, 'last_seen_at' => 0); |
| 107 |
} |
| 108 |
$byKey[$sig]['count']++; |
| 109 |
if ($ts > $byKey[$sig]['last_seen_at']) { |
| 110 |
$byKey[$sig]['last_seen_at'] = $ts; |
| 111 |
} |
| 112 |
} |
| 113 |
if (empty($byKey)) { |
| 114 |
return $out; |
| 115 |
} |
| 116 |
$list = array_values($byKey); |
| 117 |
usort($list, function ($a, $b) { |
| 118 |
$cmp = $b['count'] - $a['count']; |
| 119 |
if ($cmp !== 0) { return $cmp; } |
| 120 |
return $b['last_seen_at'] - $a['last_seen_at']; |
| 121 |
}); |
| 122 |
return array_slice($list, 0, 5); |
| 123 |
} |
| 124 |
|
| 125 |
/** |
| 126 |
* Coarse-grain an error message so different incident timestamps, |
| 127 |
* memory addresses, file paths, and line numbers fold into the same |
| 128 |
* signature. Used by probeRecentErrorSignatures to group recurring |
| 129 |
* errors. Exposed (public) so the unit test |
| 130 |
* tests/F6UrlFragmentLeakIntoErrorSignatureTest can pin the |
| 131 |
* PII-stripping behavior directly. |
| 132 |
* |
| 133 |
* @param string $msg |
| 134 |
* @return string |
| 135 |
*/ |
| 136 |
public function normalizeErrorSignature(string $msg): string { |
| 137 |
$s = $msg; |
| 138 |
// Strip absolute paths to just the basename. |
| 139 |
$s = preg_replace('#/[A-Za-z0-9_\-\./]+/([A-Za-z0-9_\-]+\.php)#', '$1', $s) ?? $s; |
| 140 |
// Collapse memory addresses, hex, and digit sequences. |
| 141 |
$s = preg_replace('/\b0x[0-9a-fA-F]+\b/', '0xN', $s) ?? $s; |
| 142 |
$s = preg_replace('/\b\d{4,}\b/', 'N', $s) ?? $s; |
| 143 |
// Collapse runs of whitespace. |
| 144 |
$s = preg_replace('/\s+/', ' ', $s) ?? $s; |
| 145 |
return trim($s); |
| 146 |
} |
| 147 |
} |
| 148 |
|