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 / logs / Logging.php

Logging.php in 404 Solution 4.3.0, at includes/logs/Logging.php

489 lines 17.8 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 /* Static functions that can be used from anywhere. */
9
10 class ABJ_404_Solution_Logging {
11
12 /** If an error happens then we will also output these.
13 * @var array<int, string>
14 */
15 private static $storedDebugMessages = array();
16
17 /** Used to store the last line sent from the debug file. */
18 const LAST_SENT_LINE = 'last_sent_line';
19
20 /** Used to store the the debug filename. */
21 const DEBUG_FILE_KEY = 'debug_file_key';
22
23 /** @var self|null */
24 private static $instance = null;
25 /**
26 * Test seam: install or clear the cached singleton instance without
27 * private-field reflection. Pass null to reset between tests; pass a
28 * configured instance (or double) to install it. Mirrors the setInstance()
29 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
30 *
31 * @param self|null $instance
32 * @return void
33 */
34 public static function setInstance($instance) {
35 self::$instance = $instance;
36 }
37
38
39 /**
40 * Return the current singleton instance without consulting the container
41 * or building a new one. Used by `abj_service()` to honor a test-installed
42 * singleton override (or any other code that has populated `$instance`
43 * directly) without forcing the container to cache a stale binding.
44 * Mirrors the `peekInstance()` pattern on PluginLogic.
45 *
46 * @return self|null
47 */
48 public static function peekInstance() {
49 return self::$instance;
50 }
51
52 /**
53 * Factory for the DI container.
54 *
55 * This avoids recursion when the container's 'logging' service is defined in terms of getInstance().
56 *
57 * @return ABJ_404_Solution_Logging
58 */
59 public static function createForContainer() {
60 // Honor a pre-existing singleton override only when it satisfies the
61 // canonical Logging contract. Sibling factories that bind
62 // `$c->get('logging')` are strictly typed against
63 // ABJ_404_Solution_Logging; returning an anonymous double from here
64 // would violate that contract and fatal at the call site. Anonymous
65 // doubles still take effect via the abj_service() override gate for
66 // callers that route through abj_service('logging') directly.
67 if (self::$instance instanceof self) {
68 // Drain any pending-errors buffer through the existing logger
69 // before returning it, so the textdomain-too-early closure
70 // contract holds even when a caller pre-populated the singleton.
71 $existing = self::$instance;
72 self::flushPendingErrorsTo($existing);
73 return $existing;
74 }
75
76 // Create a fresh instance without consulting the container.
77 $logger = new ABJ_404_Solution_Logging();
78
79 // Set the singleton before flushing so a recursive resolution
80 // through this same factory does not build a second instance and
81 // re-enter the flush loop.
82 self::$instance = $logger;
83
84 self::flushPendingErrorsTo($logger);
85
86 return $logger;
87 }
88
89 /**
90 * Drain $GLOBALS['abj404_pending_errors'] through $logger->errorMessage()
91 * and clear the buffer. Safe to call when the buffer is empty.
92 *
93 * @param self $logger
94 * @return void
95 */
96 private static function flushPendingErrorsTo(self $logger): void {
97 if (!isset($GLOBALS['abj404_pending_errors']) || !is_array($GLOBALS['abj404_pending_errors'])) {
98 return;
99 }
100 $pending = $GLOBALS['abj404_pending_errors'];
101 unset($GLOBALS['abj404_pending_errors']);
102 foreach ($pending as $message) {
103 if (is_string($message)) {
104 $logger->errorMessage($message);
105 }
106 }
107 }
108
109 /** @return self */
110 public static function getInstance() {
111 if (self::$instance !== null) {
112 return self::$instance;
113 }
114
115 // If the DI container is initialized, prefer it.
116 if (class_exists('ABJ_404_Solution_ServiceContainer')) {
117 $service = ABJ_404_Solution_ServiceContainer::safeGet('logging');
118 if ($service instanceof ABJ_404_Solution_Logging) {
119 self::$instance = $service;
120 return self::$instance;
121 }
122 }
123
124 $fresh = new ABJ_404_Solution_Logging();
125 self::$instance = $fresh;
126
127 // log any errors that were stored before the logger existed.
128 self::flushPendingErrorsTo($fresh);
129
130 return $fresh;
131 }
132
133 private function __construct() {
134 }
135
136 /** @var ABJ_404_Solution_DebugLogFileStore|null */
137 private $debugLogFileStore = null;
138 /** @var ABJ_404_Solution_DebugLogReader|null */
139 private $debugLogReader = null;
140 /** @var ABJ_404_Solution_DebugLogArchiveBuilder|null */
141 private $debugLogArchiveBuilder = null;
142 /** @var ABJ_404_Solution_DeveloperLogMailer|null */
143 private $developerLogMailer = null;
144 /** @var ABJ_404_Solution_LoggingMessageWriter|null */
145 private $messageWriter = null;
146 /** @var ABJ_404_Solution_LoggingCapabilityDiagnostics|null */
147 private $capabilityDiagnostics = null;
148 /** @var ABJ_404_Solution_LoggingFeedbackDispatcher|null */
149 private $feedbackDispatcher = null;
150 /** @var ABJ_404_Solution_LogTimestampFormatter|null */
151 private $timestampFormatter = null;
152 /** @var ABJ_404_Solution_LogDebugModeResolver|null */
153 private $debugModeResolver = null;
154
155 /** @return ABJ_404_Solution_DebugLogFileStore */
156 private function getDebugLogFileStore(): ABJ_404_Solution_DebugLogFileStore {
157 if ($this->debugLogFileStore === null) {
158 $this->debugLogFileStore = new ABJ_404_Solution_DebugLogFileStore(
159 array($this, 'sanitizeLogLine'),
160 self::DEBUG_FILE_KEY,
161 self::LAST_SENT_LINE);
162 }
163 return $this->debugLogFileStore;
164 }
165
166 /** @return ABJ_404_Solution_DebugLogReader */
167 private function getDebugLogReader(): ABJ_404_Solution_DebugLogReader {
168 if ($this->debugLogReader === null) {
169 $this->debugLogReader = new ABJ_404_Solution_DebugLogReader(
170 array($this, 'errorMessage'));
171 }
172 return $this->debugLogReader;
173 }
174
175 /** @return ABJ_404_Solution_DebugLogArchiveBuilder */
176 private function getDebugLogArchiveBuilder(): ABJ_404_Solution_DebugLogArchiveBuilder {
177 if ($this->debugLogArchiveBuilder === null) {
178 $this->debugLogArchiveBuilder = new ABJ_404_Solution_DebugLogArchiveBuilder();
179 }
180 return $this->debugLogArchiveBuilder;
181 }
182
183 /** @return ABJ_404_Solution_DeveloperLogMailer */
184 private function getDeveloperLogMailer(): ABJ_404_Solution_DeveloperLogMailer {
185 if ($this->developerLogMailer === null) {
186 $this->developerLogMailer = new ABJ_404_Solution_DeveloperLogMailer(
187 $this->getBodyFormatter(),
188 $this->getDebugLogArchiveBuilder(),
189 array($this, 'debugMessage'),
190 array($this, 'errorMessage')
191 );
192 }
193 return $this->developerLogMailer;
194 }
195
196 /** @return ABJ_404_Solution_LoggingMessageWriter */
197 private function getMessageWriter(): ABJ_404_Solution_LoggingMessageWriter {
198 if ($this->messageWriter === null) {
199 $this->messageWriter = new ABJ_404_Solution_LoggingMessageWriter(
200 array($this, 'getTimestamp'),
201 array($this, 'isDebug'),
202 array($this, 'writeLineToDebugFile'),
203 self::$storedDebugMessages
204 );
205 }
206 return $this->messageWriter;
207 }
208
209 /** @return ABJ_404_Solution_LoggingCapabilityDiagnostics */
210 private function getCapabilityDiagnostics(): ABJ_404_Solution_LoggingCapabilityDiagnostics {
211 if ($this->capabilityDiagnostics === null) {
212 $this->capabilityDiagnostics = new ABJ_404_Solution_LoggingCapabilityDiagnostics();
213 }
214 return $this->capabilityDiagnostics;
215 }
216
217 /** @return ABJ_404_Solution_LoggingFeedbackDispatcher */
218 private function getFeedbackDispatcher(): ABJ_404_Solution_LoggingFeedbackDispatcher {
219 if ($this->feedbackDispatcher === null) {
220 $this->feedbackDispatcher = new ABJ_404_Solution_LoggingFeedbackDispatcher($this);
221 }
222 return $this->feedbackDispatcher;
223 }
224
225 /** @return ABJ_404_Solution_LogTimestampFormatter */
226 private function getTimestampFormatter(): ABJ_404_Solution_LogTimestampFormatter {
227 if ($this->timestampFormatter === null) {
228 $this->timestampFormatter = new ABJ_404_Solution_LogTimestampFormatter();
229 }
230 return $this->timestampFormatter;
231 }
232
233 /** @return ABJ_404_Solution_LogDebugModeResolver */
234 private function getDebugModeResolver(): ABJ_404_Solution_LogDebugModeResolver {
235 if ($this->debugModeResolver === null) {
236 $this->debugModeResolver = new ABJ_404_Solution_LogDebugModeResolver();
237 }
238 return $this->debugModeResolver;
239 }
240
241 /** @return boolean true if debug mode is on. false otherwise. */
242 function isDebug() {
243 return $this->getDebugModeResolver()->isDebug();
244 }
245
246 /** for the current timezone.
247 * @return string */
248 function getTimestamp() {
249 return $this->getTimestampFormatter()->format();
250 }
251
252 /** Send a message to the log file if debug mode is on.
253 * This goes to a file and is used by every other class so it goes here.
254 * @param string $message
255 * @param \Throwable|null $e If present then a stack trace is included.
256 * @return void
257 */
258 function debugMessage(string $message, $e = null): void {
259 $this->getMessageWriter()->debugMessage($message, $e);
260 }
261
262 /** Send a message to the log.
263 * This goes to a file and is used by every other class so it goes here.
264 * @param string $message
265 * @return void
266 */
267 function infoMessage(string $message): void {
268 $this->getMessageWriter()->infoMessage($message);
269 }
270
271 /** Send a message to the log.
272 * This goes to a file and is used by every other class so it goes here.
273 * @param string $message
274 * @return void
275 */
276 function warn(string $message): void {
277 $this->getMessageWriter()->warn($message);
278 }
279
280 /** Always send a message to the error_log.
281 * This goes to a file and is used by every other class so it goes here.
282 * @param string $message
283 * @param \Exception|null $e
284 * @return void
285 */
286 function errorMessage(string $message, $e = null): void {
287 $this->getMessageWriter()->errorMessage($message, $e);
288 }
289
290 /** Log the user capabilities.
291 * @param string $msg
292 * @return void
293 */
294 function logUserCapabilities(string $msg): void {
295 $this->debugMessage($this->getCapabilityDiagnostics()->format($msg));
296 }
297
298 /** Write the line to the debug file.
299 *
300 * Sanitizes PII at write-time for GDPR compliance (defense in depth).
301 * Fix for disk space error (reported by 1 user - 2% of errors)
302 * Handles file write failures gracefully to prevent error loops when disk is full.
303 * Uses error suppression and returns status instead of throwing exceptions.
304 *
305 * @param string $line
306 * @return bool True on success, false on failure
307 */
308 function writeLineToDebugFile($line) {
309 return $this->getDebugLogFileStore()->writeLine((string)$line, $this->getDebugFilePath());
310 }
311
312 /** Email the log file to the plugin developer.
313 *
314 * Cron-context entry: builds a FeedbackTransport payload from the freshly-
315 * scanned latest-error line plus dedup state, and dispatches via
316 * FeedbackTransport::sendNow() (sync HTTP POST + email fallback). Returns
317 * true iff any transport (HTTP or email) succeeded; the dedup pointer is
318 * advanced before sending so a transport failure does not cause repeated
319 * sends of the same error line on the next cron tick.
320 *
321 * @return bool
322 */
323 function emailErrorLogIfNecessary(): bool {
324 return $this->getFeedbackDispatcher()->emailErrorLogIfNecessary();
325 }
326
327 /**
328 * Lazily-constructed body-formatter collaborator. Pure presentation, no
329 * dependencies, kept as a field only so it isn't reallocated every send.
330 *
331 * @return ABJ_404_Solution_ErrorEmailBodyFormatter
332 */
333 private function getBodyFormatter(): ABJ_404_Solution_ErrorEmailBodyFormatter {
334 if ($this->bodyFormatter === null) {
335 $this->bodyFormatter = new ABJ_404_Solution_ErrorEmailBodyFormatter();
336 }
337 return $this->bodyFormatter;
338 }
339
340 /** @var ABJ_404_Solution_ErrorEmailBodyFormatter|null */
341 private $bodyFormatter = null;
342
343 /**
344 * Roll a 1-in-N dice and send a full debug zip as a heartbeat if it hits.
345 * Called during daily maintenance for opted-in sites when no error email was sent.
346 *
347 * Dispatches via FeedbackTransport::sendNow() (HTTP POST + email fallback)
348 * with type='heartbeat' so the same payload shape is shared with the error
349 * path.
350 *
351 * @param int $oneInN Probability denominator (default 200 = ~once per 6 months).
352 * @return bool True if a heartbeat was sent.
353 */
354 function sendHeartbeatIfDueRandom(int $oneInN = 200): bool {
355 return $this->getFeedbackDispatcher()->sendHeartbeatIfDueRandom($oneInN);
356 }
357
358 /**
359 * Email-fallback for FeedbackTransport when the HTTP POST of an error or
360 * heartbeat report fails. Builds an HTML email body purely from the
361 * FeedbackTransport payload (single source of truth shared with the HTTP
362 * path) and attaches a zip of the current debug log file(s).
363 *
364 * Public because FeedbackTransport::sendNow() invokes it via the service
365 * container for type='error' and type='heartbeat'.
366 *
367 * @param array<string, mixed> $payload FeedbackTransport-built payload.
368 * @return bool True if wp_mail() reported success, false otherwise.
369 */
370 function emailLogFileToDeveloper(array $payload): bool {
371 return $this->getDeveloperLogMailer()->send(
372 $payload,
373 $this->getDebugFilePath(),
374 $this->getDebugFilePathOld(),
375 $this->getZipFilePath(),
376 $this->getDebugFilename()
377 );
378 }
379
380 /**
381 * @return array{num: int, line: string|null, total_error_count: int}
382 */
383 function getLatestErrorLine(): array {
384 return $this->getDebugLogReader()->getLatestErrorLine($this->getDebugFilePath());
385 }
386
387 /**
388 * Get sanitized log excerpt for support emails.
389 * Collects last 15 ERROR/WARN entries (already sanitized at write-time)
390 * plus the last 20 lines for recent context (admin actions, AJAX calls).
391 * If no errors/warnings found, includes only the last 20 lines.
392 *
393 * @return string Sanitized log excerpt or message if no errors found
394 */
395 function getSanitizedLogExcerptForSupport() {
396 return $this->getDebugLogReader()->getSanitizedLogExcerptForSupport($this->getDebugFilePath());
397 }
398
399 /**
400 * Sanitize a single log line for privacy (GDPR compliance).
401 * Delegates to PiiRedactor for all pattern matching and masking.
402 *
403 * @param string $line Log line to sanitize
404 * @return string Sanitized line with PII masked adaptively
405 */
406 public function sanitizeLogLine($line) {
407 /** @var ABJ_404_Solution_PiiRedactor|null $redactor */
408 $redactor = function_exists('abj_service_optional') ? abj_service_optional('pii_redactor') : null;
409 if (!$redactor instanceof ABJ_404_Solution_PiiRedactor) {
410 return $line;
411 }
412 return $redactor->redact($line);
413 }
414
415 /** Return the path to the debug file.
416 * @return string
417 */
418 function getDebugFilePath() {
419 return $this->getDebugLogFileStore()->getDebugFilePath();
420 }
421
422 /** @return string */
423 function getDebugFilename(): string {
424 return $this->getDebugLogFileStore()->getDebugFilename();
425 }
426
427 /** @return string */
428 function getDebugFilePathOld(): string {
429 return $this->getDebugFilePath() . "_old.txt";
430 }
431
432 /** Return the path to the file that stores the latest error line in the log file.
433 * @return string
434 */
435 function getDebugFilePathSentFile() {
436 return $this->getDebugLogFileStore()->getDebugFilePathSentFile();
437 }
438
439 /** Return the path to the zip file for sending the debug file.
440 * @return string
441 */
442 function getZipFilePath() {
443 return $this->getDebugLogFileStore()->getZipFilePath();
444 }
445
446 /** This is for legacy support. On new installations it creates a directory and returns
447 * a file path. On old installations it moved the old file to the new location.
448 * If the directory can't be created then it falls back to the old location.
449 * @param string $directory
450 * @param string $filename
451 * @return string
452 */
453 function getFilePathAndMoveOldFile($directory, $filename) {
454 return $this->getDebugLogFileStore()->getFilePathAndMoveOldFile($directory, $filename);
455 }
456
457 /** @return void */
458 function limitDebugFileSize(): void {
459 $this->getDebugLogFileStore()->limitDebugFileSize(
460 $this->getDebugFilePathSentFile(),
461 $this->getDebugFilePathOld(),
462 $this->getDebugFilePath()
463 );
464 }
465
466 /** @return void */
467 function removeLastSentErrorLineFromDatabase(): void {
468 $this->getDebugLogFileStore()->removeLastSentErrorLineFromDatabase();
469 }
470
471 /** Deletes all files named abj404_debug_*.txt
472 * @return boolean true if the file was deleted.
473 */
474 function deleteDebugFile() {
475 return $this->getDebugLogFileStore()->deleteDebugFile();
476 }
477
478 /**
479 * @return int file size in bytes
480 */
481 function getDebugFileSize() {
482 return $this->getDebugLogFileStore()->getDebugFileSize(
483 $this->getDebugFilePath(),
484 $this->getDebugFilePathOld()
485 );
486 }
487
488 }
489