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 / diagnostics / DebugLogReader.php

DebugLogReader.php in 404 Solution trunk, at includes/diagnostics/DebugLogReader.php

325 lines 11.9 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3
4 if (!defined('ABSPATH')) {
5 exit;
6 }
7
8 // allow-no-test-found: covered by tests/LoggingTest.php and tests/LogExcerptAdminActionsTest.php through public Logging entry points.
9
10 /**
11 * Reader/parser for debug-log files.
12 *
13 * Owns multiline ERROR/WARN parsing for cron error reports and support
14 * excerpts. It does not know where the log file lives; callers provide a
15 * concrete path from the debug-log store.
16 *
17 * @phpstan-type DebugLogSnapshot array{status: string, num: int, line: string|null,
18 * total_error_count: int, file_size: int, tail: string, latest_error_offset: int,
19 * error_context_start: int, error_context: string,
20 * error_entries: array<int, array<int, string>>, recent_lines: array<int, string>}
21 */
22 class ABJ_404_Solution_DebugLogReader {
23
24 /** Existing wire budget shared by the recent tail and anchored evidence. */
25 const REPORT_EVIDENCE_MAX_BYTES = 262144;
26
27 /** Maximum bytes reserved for an error that sits outside the recent tail. */
28 const ERROR_EXCERPT_MAX_BYTES = 65536;
29
30 /** Context retained immediately before the latest reportable error. */
31 const ERROR_CONTEXT_PREFIX_BYTES = 8192;
32
33 /** @var callable */
34 private $errorLogger;
35
36 /** @phpstan-var array<string, DebugLogSnapshot> request-scoped file snapshots */
37 private $snapshots = array();
38
39 /** @param callable $errorLogger Receives message and optional exception. */
40 public function __construct(callable $errorLogger) {
41 $this->errorLogger = $errorLogger;
42 }
43
44 /**
45 * @return array{status: string, num: int, line: string|null, total_error_count: int,
46 * file_size: int, tail: string, latest_error_offset: int, error_context_start: int,
47 * error_context: string, error_entries: array<int, array<int, string>>,
48 * recent_lines: array<int, string>} Immutable request-scoped file snapshot.
49 */
50 public function getSnapshot(string $debugPath): array {
51 return $this->snapshot($debugPath);
52 }
53
54 /** @return array{num: int, line: string|null, total_error_count: int} */
55 public function getLatestErrorLine(string $debugPath): array {
56 $evidence = $this->snapshot($debugPath);
57 return array(
58 'num' => $evidence['num'],
59 'line' => $evidence['line'],
60 'total_error_count' => $evidence['total_error_count'],
61 );
62 }
63
64 /**
65 * The one physical read used by error reports, support requests, previews,
66 * and dedupe checks during this request.
67 *
68 * @return array{status: string, num: int, line: string|null, total_error_count: int,
69 * file_size: int, tail: string, latest_error_offset: int, error_context_start: int,
70 * error_context: string, error_entries: array<int, array<int, string>>,
71 * recent_lines: array<int, string>}
72 */
73 private function snapshot(string $debugFilePath): array {
74 if (array_key_exists($debugFilePath, $this->snapshots)) {
75 return $this->snapshots[$debugFilePath];
76 }
77 $snapshot = $this->emptySnapshot('missing');
78 if ($debugFilePath === '' || !file_exists($debugFilePath)) {
79 return $this->snapshots[$debugFilePath] = $snapshot;
80 }
81
82 $observedSize = @filesize($debugFilePath);
83 if (is_int($observedSize)) {
84 $snapshot['file_size'] = $observedSize;
85 }
86
87 $handle = @fopen($debugFilePath, 'rb');
88 if (!is_resource($handle)) {
89 call_user_func($this->errorLogger, 'Error reading log file (open failed).');
90 $snapshot['status'] = 'unreadable';
91 return $this->snapshots[$debugFilePath] = $snapshot;
92 }
93
94 $errorEntries = array();
95 $recentLines = array();
96 $currentEntry = array();
97 $collectingEntry = false;
98 $collectingErrorLines = false;
99 $linesRead = 0;
100 $bytesRead = 0;
101 $tailChunks = array();
102 $tailHead = 0;
103 $tailBytes = 0;
104 $previousChunks = array();
105 $previousHead = 0;
106 $previousByteCount = 0;
107 $contextActive = false;
108 try {
109 while (($line = fgets($handle)) !== false) {
110 $line = (string)$line;
111 $lineOffset = $bytesRead;
112 $lineBytes = strlen($line);
113 $bytesRead += $lineBytes;
114 $linesRead++;
115 $this->appendBoundedChunk(
116 $tailChunks,
117 $tailHead,
118 $tailBytes,
119 $line,
120 self::REPORT_EVIDENCE_MAX_BYTES
121 );
122 $this->collectSupportExcerptLine(
123 $line,
124 $errorEntries,
125 $recentLines,
126 $currentEntry,
127 $collectingEntry
128 );
129
130 $errorMarker = stripos($line, '(ERROR)');
131 $isError = $errorMarker !== false
132 && stripos($line, 'SQL query error: DELETE command denied to user') === false;
133 if ($isError) {
134 $snapshot['num'] = $linesRead;
135 $snapshot['line'] = $line;
136 $snapshot['total_error_count']++;
137 $markerOffset = (int)$errorMarker;
138 $snapshot['latest_error_offset'] = $lineOffset + $markerOffset;
139 $lineContextStart = max(0, $markerOffset - self::ERROR_CONTEXT_PREFIX_BYTES);
140 $prefix = $lineContextStart === 0
141 ? implode('', $previousChunks) : '';
142 $snapshot['error_context_start'] = max(
143 0,
144 $lineOffset + $lineContextStart - strlen($prefix)
145 );
146 $snapshot['error_context'] = substr(
147 $prefix . substr($line, $lineContextStart),
148 0,
149 self::ERROR_EXCERPT_MAX_BYTES
150 );
151 $contextActive = strlen($snapshot['error_context']) < self::ERROR_EXCERPT_MAX_BYTES;
152 $collectingErrorLines = true;
153 } else {
154 if ($collectingErrorLines && $this->isContinuationLine($line)) {
155 $snapshot['line'] .= "<BR/>\n" . $line;
156 } else {
157 $collectingErrorLines = false;
158 }
159 if ($contextActive) {
160 $remaining = self::ERROR_EXCERPT_MAX_BYTES - strlen($snapshot['error_context']);
161 $snapshot['error_context'] .= substr($line, 0, $remaining);
162 $contextActive = strlen($snapshot['error_context']) < self::ERROR_EXCERPT_MAX_BYTES;
163 }
164 }
165 $this->appendBoundedChunk(
166 $previousChunks,
167 $previousHead,
168 $previousByteCount,
169 $line,
170 self::ERROR_CONTEXT_PREFIX_BYTES
171 );
172 }
173
174 $this->storeCurrentSupportEntry($errorEntries, $currentEntry);
175 $snapshot['status'] = 'ok';
176 $snapshot['file_size'] = $bytesRead;
177 $snapshot['tail'] = implode('', $tailChunks);
178 $snapshot['error_entries'] = $errorEntries;
179 $snapshot['recent_lines'] = $recentLines;
180 } catch (\Throwable $e) {
181 call_user_func(
182 $this->errorLogger,
183 'Error reading log file snapshot: ' . get_class($e) . ': ' . $e->getMessage(),
184 $e instanceof \Exception ? $e : null
185 );
186 $snapshot = $this->emptySnapshot('unreadable');
187 } finally {
188 fclose($handle);
189 }
190 return $this->snapshots[$debugFilePath] = $snapshot;
191 }
192
193 /**
194 * @return array{status: string, num: int, line: string|null, total_error_count: int,
195 * file_size: int, tail: string, latest_error_offset: int, error_context_start: int,
196 * error_context: string, error_entries: array<int, array<int, string>>,
197 * recent_lines: array<int, string>}
198 */
199 private function emptySnapshot(string $status): array {
200 return array(
201 'status' => $status,
202 'num' => -1,
203 'line' => null,
204 'total_error_count' => 0,
205 'file_size' => 0,
206 'tail' => '',
207 'latest_error_offset' => -1,
208 'error_context_start' => 0,
209 'error_context' => '',
210 'error_entries' => array(),
211 'recent_lines' => array(),
212 );
213 }
214
215 /**
216 * Append without repeatedly copying the whole retained window. Numeric
217 * keys may be sparse after eviction; implode intentionally ignores keys.
218 *
219 * @param array<int, string> $chunks
220 */
221 private function appendBoundedChunk(
222 array &$chunks,
223 int &$head,
224 int &$byteCount,
225 string $addition,
226 int $maxBytes
227 ): void {
228 $chunks[] = $addition;
229 $byteCount += strlen($addition);
230 while ($byteCount > $maxBytes && isset($chunks[$head])) {
231 $overflow = $byteCount - $maxBytes;
232 $headBytes = strlen($chunks[$head]);
233 if ($headBytes <= $overflow) {
234 unset($chunks[$head]);
235 $head++;
236 $byteCount -= $headBytes;
237 continue;
238 }
239 $trimmed = substr($chunks[$head], $overflow);
240 if ($trimmed === false) {
241 throw new \RuntimeException('Unable to trim bounded debug-log evidence chunk.');
242 }
243 $chunks[$head] = $trimmed;
244 $byteCount -= $overflow;
245 }
246 }
247
248 /**
249 * @param array<int, array<int, string>> $errorEntries
250 * @param array<int, string> $recentLines
251 * @param array<int, string> $currentEntry
252 * @param bool $collectingEntry
253 * @return void
254 */
255 private function collectSupportExcerptLine(
256 string $line,
257 array &$errorEntries,
258 array &$recentLines,
259 array &$currentEntry,
260 bool &$collectingEntry
261 ): void {
262 $this->rememberRecentLine($recentLines, $line);
263
264 if ($this->isReportableWarningOrError($line)) {
265 if ($collectingEntry) {
266 $this->storeCurrentSupportEntry($errorEntries, $currentEntry);
267 }
268 $currentEntry = array($line);
269 $collectingEntry = true;
270 return;
271 }
272
273 if ($collectingEntry && $this->isContinuationLine($line)) {
274 $currentEntry[] = $line;
275 return;
276 }
277
278 if ($collectingEntry) {
279 $this->storeCurrentSupportEntry($errorEntries, $currentEntry);
280 }
281 $collectingEntry = false;
282 $currentEntry = array();
283 }
284
285 /**
286 * @param array<int, string> $recentLines
287 * @return void
288 */
289 private function rememberRecentLine(array &$recentLines, string $line): void {
290 $recentLines[] = $line;
291 if (count($recentLines) > 20) {
292 array_shift($recentLines);
293 }
294 }
295
296 private function isReportableWarningOrError(string $line): bool {
297 $hasError = stripos($line, '(ERROR)') !== false;
298 $hasWarn = stripos($line, '(WARN)') !== false;
299 $isDeleteError = stripos($line, 'SQL query error: DELETE command denied to user') !== false;
300 return ($hasError || $hasWarn) && !$isDeleteError;
301 }
302
303 private function isContinuationLine(string $line): bool {
304 $f = abj_service('functions');
305 return !$f->regexMatch("^\d{4}[-]\d{2}[-]\d{2} .*\(\w+\):\s.*$", $line);
306 }
307
308 /**
309 * @param array<int, array<int, string>> $errorEntries
310 * @param array<int, string> $currentEntry
311 * @return void
312 */
313 private function storeCurrentSupportEntry(array &$errorEntries, array &$currentEntry): void {
314 if (empty($currentEntry)) {
315 return;
316 }
317 $errorEntries[] = $currentEntry;
318 if (count($errorEntries) > 15) {
319 array_shift($errorEntries);
320 }
321 $currentEntry = array();
322 }
323
324 }
325