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 / DebugLogFileStore.php

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

232 lines 9.4 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 public Logging entry-point tests for debug writes and log rotation.
9
10 /**
11 * File-store for the plugin debug log.
12 *
13 * Owns debug-log path derivation, sanitized line writes, rotation/deletion,
14 * size accounting, and the dedupe-pointer reset that must happen whenever
15 * line numbers become invalid. It intentionally does not decide when to log
16 * or send feedback reports; ABJ_404_Solution_Logging remains that facade.
17 */
18 class ABJ_404_Solution_DebugLogFileStore {
19
20 /** @var callable */
21 private $sanitizeLogLine;
22
23 /** @var ABJ_404_Solution_LoggingStateStore The single recursion-safe chokepoint for logging-owned scalars. */
24 private $stateStore;
25
26 /**
27 * Per-blog memo of the resolved debug filename. Logging writes one file
28 * for the lifetime of a single blog context, so the key is read/generated
29 * once per blog and reused; this also keeps the filename stable when the
30 * underlying option write does not round-trip within the request (e.g. a
31 * deferred object cache), which would otherwise make getDebugFilename()
32 * regenerate the key and have deleteDebugFile() wipe a file just written.
33 * Cleared by deleteDebugFile(). Scoped to the blog id active at cache time
34 * (not just the request) because the underlying debug_file_key lives in
35 * abj404_settings, a per-blog option: a multisite background batch that
36 * switch_to_blog()s mid-request would otherwise permanently pin the
37 * memoized filename to whichever blog happened to trigger the first
38 * resolution, silently splitting the debug log across two files once the
39 * blog context restores.
40 *
41 * @var string|null
42 */
43 private $cachedDebugFilename = null;
44
45 /** @var int|null Blog id $cachedDebugFilename was resolved for. */
46 private $cachedDebugFilenameBlogId = null;
47
48 /**
49 * @param callable $sanitizeLogLine Receives a raw line and returns the sanitized line to write.
50 * @param ABJ_404_Solution_LoggingStateStore $stateStore Recursion-safe accessor for the
51 * debug-file suffix and last-sent-line scalars (raw read/write only).
52 */
53 public function __construct(callable $sanitizeLogLine, ABJ_404_Solution_LoggingStateStore $stateStore) {
54 $this->sanitizeLogLine = $sanitizeLogLine;
55 $this->stateStore = $stateStore;
56 }
57
58 /**
59 * Write one sanitized line to the active debug file.
60 *
61 * @param string $line
62 * @param string $debugFilePath
63 * @return bool True on success, false on disk/permission failure.
64 */
65 public function writeLine(string $line, string $debugFilePath): bool {
66 $sanitizedLine = (string)call_user_func($this->sanitizeLogLine, $line);
67 $result = @file_put_contents($debugFilePath, $sanitizedLine . "\n", FILE_APPEND);
68
69 if ($result === false) {
70 abj404_logPhpFallback(
71 'logger-internal',
72 'Unable to write to debug log (possibly disk full): ' . $debugFilePath
73 );
74 return false;
75 }
76
77 return true;
78 }
79
80 /** @return string */
81 public function getDebugFilePath(): string {
82 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), $this->getDebugFilename());
83 }
84
85 /** @return string */
86 public function getDebugFilename(): string {
87 $currentBlogId = function_exists('get_current_blog_id') ? (int)get_current_blog_id() : 0;
88 if ($this->cachedDebugFilename !== null && $this->cachedDebugFilenameBlogId === $currentBlogId) {
89 return $this->cachedDebugFilename;
90 }
91 // A blog switch mid-request (multisite background batch) re-derives the
92 // filename for the newly-active blog without wiping files: the glob
93 // delete below is orphan cleanup for a genuinely fresh boot, not a
94 // routine step of every blog switch. On a shared-uploads-dir multisite
95 // config (the UPLOADS constant), the glob is not blog-scoped the way
96 // abj404_getUploadsDir() usually is, so running it on every keyless
97 // blog visited mid-request could delete another blog's in-use file.
98 $isFirstResolutionThisRequest = ($this->cachedDebugFilename === null);
99 try {
100 // Logging MUST read its metadata via the raw, side-effect-free
101 // state store, never getOptions(). getOptions() runs the normalize
102 // pipeline, which logs a warning on any schema-validation failure;
103 // that warning re-enters this method and recurses without bound
104 // until memory is exhausted (the 4.3.0 "broken sites after the
105 // latest update" OOM at PluginLogicOptionsResolver line ~250). The
106 // store reaches storage with the raw accessor only.
107 $debugFileKey = $this->stateStore->getDebugFileKey();
108
109 if ($debugFileKey === null || trim($debugFileKey) === '') {
110 if ($isFirstResolutionThisRequest) {
111 $this->deleteDebugFile();
112 }
113
114 $syncUtils = abj_service('sync_utils');
115 if (!is_object($syncUtils) || !method_exists($syncUtils, 'uniqidReal')) {
116 return 'abj404_debug.txt';
117 }
118 $debugFileKey = $syncUtils->uniqidReal();
119 $this->stateStore->setDebugFileKey($debugFileKey);
120 }
121
122 $this->cachedDebugFilename = 'abj404_debug_' . $debugFileKey . '.txt';
123 $this->cachedDebugFilenameBlogId = $currentBlogId;
124 return $this->cachedDebugFilename;
125 } catch (\Throwable $e) { // allow-silent-catch: debug filename derivation; fallback name keeps logging available during degraded boot
126 return 'abj404_debug.txt';
127 }
128 }
129
130 /** @return string */
131 public function getDebugFilePathOld(): string {
132 return $this->getDebugFilePath() . "_old.txt";
133 }
134
135 /** @return string */
136 public function getDebugFilePathSentFile(): string {
137 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug_sent_line.txt');
138 }
139
140 /** @return string */
141 public function getZipFilePath(): string {
142 return $this->getFilePathAndMoveOldFile(abj404_getUploadsDir(), 'abj404_debug.zip');
143 }
144
145 /**
146 * Create the storage directory and migrate a legacy root-level file if present.
147 *
148 * @param string $directory
149 * @param string $filename
150 * @return string
151 */
152 public function getFilePathAndMoveOldFile($directory, $filename): string {
153 if (!ABJ_404_Solution_FileSystemService::createDirectoryWithErrorMessages($directory)) {
154 return ABJ404_PATH . $filename;
155 }
156
157 if (file_exists(ABJ404_PATH . $filename)) {
158 rename(ABJ404_PATH . $filename, $directory . $filename);
159 }
160
161 return $directory . $filename;
162 }
163
164 /** @return void */
165 public function limitDebugFileSize(string $sentFilePath, string $oldDebugFilePath, string $debugFilePath): void {
166 if (file_exists($sentFilePath)) {
167 ABJ_404_Solution_FileSystemService::safeUnlink($sentFilePath);
168 }
169
170 $this->removeLastSentErrorLineFromDatabase();
171 ABJ_404_Solution_FileSystemService::safeUnlink($oldDebugFilePath);
172 rename($debugFilePath, $oldDebugFilePath);
173 }
174
175 /** @return void */
176 public function removeLastSentErrorLineFromDatabase(): void {
177 // Raw write only via the recursion-safe state store -- logging metadata
178 // must not pass through the getOptions()/updateOptions() normalize-and-
179 // log pipeline (recursion).
180 $this->stateStore->setLastSentLine(0);
181 }
182
183 /** @return bool true if every matching debug file was deleted. */
184 public function deleteDebugFile(): bool {
185 $allIsWell = true;
186
187 if (file_exists($this->getDebugFilePathSentFile())) {
188 ABJ_404_Solution_FileSystemService::safeUnlink($this->getDebugFilePathSentFile());
189 }
190 $this->removeLastSentErrorLineFromDatabase();
191
192 $uploadDir = abj404_getUploadsDir();
193 if (is_dir($uploadDir)) {
194 $files = glob($uploadDir . '/abj404_debug_*.txt');
195 if (!is_array($files)) {
196 $files = array();
197 }
198 foreach ($files as $file) {
199 if (is_file($file) && !ABJ_404_Solution_FileSystemService::safeUnlink($file)) {
200 $allIsWell = false;
201 }
202 }
203 }
204
205 // Raw write only via the recursion-safe state store -- clearing the
206 // debug-file key must not pass through getOptions()/updateOptions(),
207 // whose normalize step logs on validation failure and would re-enter
208 // logging from this delete-during-logging path (the 4.3.0 recursion).
209 // The in-request filename memo is dropped too so the next
210 // getDebugFilename() re-derives the key.
211 $this->stateStore->setDebugFileKey(null);
212 $this->cachedDebugFilename = null;
213 $this->cachedDebugFilenameBlogId = null;
214
215 return $allIsWell;
216 }
217
218 /** @return int file size in bytes */
219 public function getDebugFileSize(string $debugFilePath, string $oldDebugFilePath): int {
220 $file1Size = 0;
221 $file2Size = 0;
222 if (file_exists($debugFilePath)) {
223 $file1Size = (int)filesize($debugFilePath);
224 }
225 if (file_exists($oldDebugFilePath)) {
226 $file2Size = (int)filesize($oldDebugFilePath);
227 }
228
229 return $file1Size + $file2Size;
230 }
231 }
232