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

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

501 lines 18.3 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 require_once dirname(__DIR__) . '/feedback/SupportLogExcerpt.php';
9
10 /* Static functions that can be used from anywhere. */
11
12 class ABJ_404_Solution_Logging {
13
14 /** If an error happens then we will also output these.
15 * @var array<int, string>
16 */
17 private static $storedDebugMessages = array();
18
19 /** Used to store the last line sent from the debug file. */
20 const LAST_SENT_LINE = 'last_sent_line';
21
22 /** Used to store the the debug filename. */
23 const DEBUG_FILE_KEY = 'debug_file_key';
24
25 /** @var self|null */
26 private static $instance = null;
27 /**
28 * Test seam: install or clear the cached singleton instance without
29 * private-field reflection. Pass null to reset between tests; pass a
30 * configured instance (or double) to install it. Mirrors the setInstance()
31 * contract on DataAccess / PluginLogic (M105 singleton-reset seam).
32 *
33 * @param self|null $instance
34 * @return void
35 */
36 public static function setInstance($instance) {
37 self::$instance = $instance;
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 private function __construct() {
133 }
134
135 /** @var ABJ_404_Solution_DebugLogFileStore|null */
136 private $debugLogFileStore = null;
137 /** @var ABJ_404_Solution_DebugLogReader|null */
138 private $debugLogReader = null;
139 /** @var ABJ_404_Solution_DebugLogArchiveBuilder|null */
140 private $debugLogArchiveBuilder = null;
141 /** @var ABJ_404_Solution_DeveloperLogMailer|null */
142 private $developerLogMailer = null;
143 /** @var ABJ_404_Solution_LoggingMessageWriter|null */
144 private $messageWriter = null;
145 /** @var ABJ_404_Solution_LoggingCapabilityDiagnostics|null */
146 private $capabilityDiagnostics = null;
147 /** @var ABJ_404_Solution_LoggingFeedbackDispatcher|null */
148 private $feedbackDispatcher = null;
149 /** @var ABJ_404_Solution_LogTimestampFormatter|null */
150 private $timestampFormatter = null;
151 /** @var ABJ_404_Solution_LogDebugModeResolver|null */
152 private $debugModeResolver = null;
153
154 /** @return ABJ_404_Solution_DebugLogFileStore */
155 private function getDebugLogFileStore(): ABJ_404_Solution_DebugLogFileStore {
156 if ($this->debugLogFileStore === null) {
157 $this->debugLogFileStore = new ABJ_404_Solution_DebugLogFileStore(
158 array($this, 'sanitizeLogLine'),
159 ABJ_404_Solution_LoggingStateStore::resolve());
160 }
161 return $this->debugLogFileStore;
162 }
163
164 /** @return ABJ_404_Solution_DebugLogReader */
165 private function getDebugLogReader(): ABJ_404_Solution_DebugLogReader {
166 if ($this->debugLogReader === null) {
167 $this->debugLogReader = new ABJ_404_Solution_DebugLogReader(
168 array($this, 'errorMessage'));
169 }
170 return $this->debugLogReader;
171 }
172
173 /** @return ABJ_404_Solution_DebugLogArchiveBuilder */
174 private function getDebugLogArchiveBuilder(): ABJ_404_Solution_DebugLogArchiveBuilder {
175 if ($this->debugLogArchiveBuilder === null) {
176 $this->debugLogArchiveBuilder = new ABJ_404_Solution_DebugLogArchiveBuilder();
177 }
178 return $this->debugLogArchiveBuilder;
179 }
180
181 /** @return ABJ_404_Solution_DeveloperLogMailer */
182 private function getDeveloperLogMailer(): ABJ_404_Solution_DeveloperLogMailer {
183 if ($this->developerLogMailer === null) {
184 $this->developerLogMailer = new ABJ_404_Solution_DeveloperLogMailer(
185 $this->getBodyFormatter(),
186 $this->getDebugLogArchiveBuilder(),
187 array($this, 'debugMessage'),
188 array($this, 'errorMessage')
189 );
190 }
191 return $this->developerLogMailer;
192 }
193
194 /** @return ABJ_404_Solution_LoggingMessageWriter */
195 private function getMessageWriter(): ABJ_404_Solution_LoggingMessageWriter {
196 if ($this->messageWriter === null) {
197 $this->messageWriter = new ABJ_404_Solution_LoggingMessageWriter(
198 array($this, 'getTimestamp'),
199 array($this, 'isDebug'),
200 array($this, 'writeLineToDebugFile'),
201 self::$storedDebugMessages,
202 array('ABJ_404_Solution_RoutineLoggingBridge', 'trace')
203 );
204 }
205 return $this->messageWriter;
206 }
207
208 /** @return ABJ_404_Solution_LoggingCapabilityDiagnostics */
209 private function getCapabilityDiagnostics(): ABJ_404_Solution_LoggingCapabilityDiagnostics {
210 if ($this->capabilityDiagnostics === null) {
211 $this->capabilityDiagnostics = new ABJ_404_Solution_LoggingCapabilityDiagnostics();
212 }
213 return $this->capabilityDiagnostics;
214 }
215
216 /** @return ABJ_404_Solution_LoggingFeedbackDispatcher */
217 private function getFeedbackDispatcher(): ABJ_404_Solution_LoggingFeedbackDispatcher {
218 if ($this->feedbackDispatcher === null) {
219 $this->feedbackDispatcher = new ABJ_404_Solution_LoggingFeedbackDispatcher($this);
220 }
221 return $this->feedbackDispatcher;
222 }
223
224 /** @return ABJ_404_Solution_LogTimestampFormatter */
225 private function getTimestampFormatter(): ABJ_404_Solution_LogTimestampFormatter {
226 if ($this->timestampFormatter === null) {
227 $this->timestampFormatter = new ABJ_404_Solution_LogTimestampFormatter();
228 }
229 return $this->timestampFormatter;
230 }
231
232 /** @return ABJ_404_Solution_LogDebugModeResolver */
233 private function getDebugModeResolver(): ABJ_404_Solution_LogDebugModeResolver {
234 if ($this->debugModeResolver === null) {
235 $this->debugModeResolver = new ABJ_404_Solution_LogDebugModeResolver();
236 }
237 return $this->debugModeResolver;
238 }
239
240 /** @return boolean true if debug mode is on. false otherwise. */
241 function isDebug() {
242 return $this->getDebugModeResolver()->isDebug();
243 }
244
245 /** for the current timezone.
246 * @return string */
247 function getTimestamp() {
248 return $this->getTimestampFormatter()->format();
249 }
250
251 /** Send a message to the log file if debug mode is on.
252 * This goes to a file and is used by every other class so it goes here.
253 * @param string $message
254 * @param \Throwable|null $e If present then a stack trace is included.
255 * @return void
256 */
257 function debugMessage(string $message, $e = null): void {
258 $this->getMessageWriter()->debugMessage($message, $e);
259 }
260
261 /** Send a message to the log.
262 * This goes to a file and is used by every other class so it goes here.
263 * @param string $message
264 * @return void
265 */
266 function infoMessage(string $message): void {
267 $this->getMessageWriter()->infoMessage($message);
268 }
269 /** Send a message to the log.
270 * This goes to a file and is used by every other class so it goes here.
271 * @param string $message
272 * @return void
273 */
274 function warn(string $message): void {
275 $this->getMessageWriter()->warn($message);
276 }
277
278 /** Always send a message to the error_log.
279 * This goes to a file and is used by every other class so it goes here.
280 * @param string $message
281 * @param \Exception|null $e
282 * @return void
283 */
284 function errorMessage(string $message, $e = null): void {
285 $this->getMessageWriter()->errorMessage($message, $e);
286 }
287
288 /** Log the user capabilities.
289 * @param string $msg
290 * @return void
291 */
292 function logUserCapabilities(string $msg): void {
293 $this->debugMessage($this->getCapabilityDiagnostics()->format($msg));
294 }
295
296 /** Write the line to the debug file.
297 *
298 * Sanitizes PII at write-time for GDPR compliance (defense in depth).
299 * Fix for disk space error (reported by 1 user - 2% of errors)
300 * Handles file write failures gracefully to prevent error loops when disk is full.
301 * Uses error suppression and returns status instead of throwing exceptions.
302 *
303 * @param string $line
304 * @return bool True on success, false on failure
305 */
306 function writeLineToDebugFile($line) {
307 $debugFilePath = ABJ_404_Solution_RoutineLoggingBridge::traceAuthorized(
308 'path_resolution',
309 'path_resolution',
310 fn() => $this->getDebugFilePath()
311 );
312 return ABJ_404_Solution_RoutineLoggingBridge::traceAuthorized(
313 'write',
314 'write_flush_return',
315 fn() => $this->getDebugLogFileStore()->writeLine((string)$line, $debugFilePath)
316 );
317 }
318
319 /** Email the log file to the plugin developer.
320 *
321 * Cron-context entry: builds a FeedbackTransport payload from the freshly-
322 * scanned latest-error line plus dedup state, and dispatches via
323 * FeedbackTransport::sendNow() (sync HTTP POST + email fallback). Returns
324 * true iff any transport (HTTP or email) succeeded; the dedup pointer is
325 * advanced before sending so a transport failure does not cause repeated
326 * sends of the same error line on the next cron tick.
327 *
328 * @return bool
329 */
330 function emailErrorLogIfNecessary(): bool {
331 return $this->getFeedbackDispatcher()->emailErrorLogIfNecessary();
332 }
333
334 /**
335 * Drain a pending crash beacon (a fatal/OOM that could not phone home at the
336 * time) and report it as a post-mortem `error` report. Called during daily
337 * maintenance for opted-in sites.
338 *
339 * @return bool True if a crash beacon was reported.
340 */
341 function drainCrashBeaconIfNecessary(): bool {
342 return $this->getFeedbackDispatcher()->drainCrashBeaconIfNecessary();
343 }
344
345 /**
346 * Lazily-constructed body-formatter collaborator. Pure presentation, no
347 * dependencies, kept as a field only so it isn't reallocated every send.
348 *
349 * @return ABJ_404_Solution_ErrorEmailBodyFormatter
350 */
351 private function getBodyFormatter(): ABJ_404_Solution_ErrorEmailBodyFormatter {
352 if ($this->bodyFormatter === null) {
353 $this->bodyFormatter = new ABJ_404_Solution_ErrorEmailBodyFormatter();
354 }
355 return $this->bodyFormatter;
356 }
357
358 /** @var ABJ_404_Solution_ErrorEmailBodyFormatter|null */
359 private $bodyFormatter = null;
360
361 /**
362 * Send the weekly status heartbeat when its deterministic cadence is due.
363 * Called during daily maintenance for opted-in sites when no error email
364 * was sent.
365 *
366 * @return bool True if a heartbeat was sent successfully.
367 */
368 function sendHeartbeatIfDueWeekly(): bool {
369 return $this->getFeedbackDispatcher()->sendHeartbeatIfDueWeekly();
370 }
371
372 /**
373 * Email-fallback for FeedbackTransport when the HTTP POST of an error or
374 * heartbeat report fails. Builds an HTML email body purely from the
375 * FeedbackTransport payload (single source of truth shared with the HTTP
376 * path) and attaches a zip of the current debug log file(s).
377 *
378 * Public because FeedbackTransport::sendNow() invokes it via the service
379 * container for type='error' and type='heartbeat'.
380 *
381 * @param array<string, mixed> $payload FeedbackTransport-built payload.
382 * @return bool True if wp_mail() reported success, false otherwise.
383 */
384 function emailLogFileToDeveloper(array $payload): bool {
385 return $this->getDeveloperLogMailer()->send(
386 $payload,
387 $this->getDebugFilePath(),
388 $this->getDebugFilePathOld(),
389 $this->getZipFilePath(),
390 $this->getDebugFilename()
391 );
392 }
393
394 /** @return array{num: int, line: string|null, total_error_count: int} */
395 function getLatestErrorLine(): array {
396 return $this->getDebugLogReader()->getLatestErrorLine($this->getDebugFilePath());
397 }
398
399 /** @return array<string, mixed> Request-scoped feedback snapshot. */
400 function getDebugLogSnapshot(): array {
401 return $this->getDebugLogReader()->getSnapshot($this->getDebugFilePath());
402 }
403
404 /** @return string Sanitized support excerpt from the same snapshot. */
405 function getSanitizedLogExcerptForSupport() {
406 return ABJ_404_Solution_SupportLogExcerpt::formatSnapshot(
407 $this->getDebugLogReader()->getSnapshot($this->getDebugFilePath())
408 );
409 }
410
411 /**
412 * Sanitize a single log line for privacy (GDPR compliance).
413 * Delegates to PiiRedactor for all pattern matching and masking.
414 *
415 * @param string $line Log line to sanitize
416 * @return string Sanitized line with PII masked adaptively
417 */
418 public function sanitizeLogLine($line) {
419 /** @var ABJ_404_Solution_PiiRedactor|null $redactor */
420 $redactor = function_exists('abj_service_optional') ? abj_service_optional('pii_redactor') : null;
421 if (!$redactor instanceof ABJ_404_Solution_PiiRedactor) {
422 return $line;
423 }
424 return $redactor->redact($line);
425 }
426
427 /** Return the path to the debug file.
428 * @return string
429 */
430 function getDebugFilePath() {
431 return $this->getDebugLogFileStore()->getDebugFilePath();
432 }
433
434 /** @return string */
435 function getDebugFilename(): string {
436 return $this->getDebugLogFileStore()->getDebugFilename();
437 }
438
439 /** @return string */
440 function getDebugFilePathOld(): string {
441 return $this->getDebugFilePath() . "_old.txt";
442 }
443
444 /** Return the path to the file that stores the latest error line in the log file.
445 * @return string
446 */
447 function getDebugFilePathSentFile() {
448 return $this->getDebugLogFileStore()->getDebugFilePathSentFile();
449 }
450
451 /** Return the path to the zip file for sending the debug file.
452 * @return string
453 */
454 function getZipFilePath() {
455 return $this->getDebugLogFileStore()->getZipFilePath();
456 }
457
458 /** This is for legacy support. On new installations it creates a directory and returns
459 * a file path. On old installations it moved the old file to the new location.
460 * If the directory can't be created then it falls back to the old location.
461 * @param string $directory
462 * @param string $filename
463 * @return string
464 */
465 function getFilePathAndMoveOldFile($directory, $filename) {
466 return $this->getDebugLogFileStore()->getFilePathAndMoveOldFile($directory, $filename);
467 }
468
469 /** @return void */
470 function limitDebugFileSize(): void {
471 $this->getDebugLogFileStore()->limitDebugFileSize(
472 $this->getDebugFilePathSentFile(),
473 $this->getDebugFilePathOld(),
474 $this->getDebugFilePath()
475 );
476 }
477
478 /** @return void */
479 function removeLastSentErrorLineFromDatabase(): void {
480 $this->getDebugLogFileStore()->removeLastSentErrorLineFromDatabase();
481 }
482
483 /** Deletes all files named abj404_debug_*.txt
484 * @return boolean true if the file was deleted.
485 */
486 function deleteDebugFile() {
487 return $this->getDebugLogFileStore()->deleteDebugFile();
488 }
489
490 /**
491 * @return int file size in bytes
492 */
493 function getDebugFileSize() {
494 return $this->getDebugLogFileStore()->getDebugFileSize(
495 $this->getDebugFilePath(),
496 $this->getDebugFilePathOld()
497 );
498 }
499
500 }
501