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

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

148 lines 5.8 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 (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