Bearsampp 2026.9.23
Loading...
Searching...
No Matches
class.log.php
Go to the documentation of this file.
1<?php
2/*
3 *
4 * * Copyright (c) 2022-2025 Bearsampp
5 * * License: GNU General Public License version 3 or later; see LICENSE.txt
6 * * Website: https://bearsampp.com
7 * * Github: https://github.com/Bearsampp
8 *
9 */
10
29class Log
30{
31 const ERROR = 'ERROR';
32 const WARNING = 'WARNING';
33 const INFO = 'INFO';
34 const DEBUG = 'DEBUG';
35 const TRACE = 'TRACE';
36
38 const MAX_QUEUE_CONTENT_LENGTH = 1048576; // 1 MB
39
41 const MAX_QUEUE_BATCH = 200;
42
45
47 private static $logBuffer = [];
48
50 private static $logBufferSize = 50;
51
53 private static $shutdownRegistered = false;
54
56 private static $logStats = [
57 'buffered' => 0,
58 'flushed' => 0,
59 'writes' => 0,
60 'async' => 0,
61 ];
62
64 private static $silentMode = false;
65
67 private static $asyncQueueDir = null;
68
70 private static $asyncEnabled = true;
71
73 private static $maxAsyncQueueBatch = 5;
74
84 public static function init()
85 {
86 if (!self::$shutdownRegistered) {
87 register_shutdown_function([__CLASS__, 'flush']);
88 register_shutdown_function([__CLASS__, 'flushAsyncQueue']);
89 self::$shutdownRegistered = true;
90
91 // Initialize async queue directory
93
94 // Auto-disable async when TRACE logging is enabled (for live log monitoring)
96 }
97 }
98
106 private static function checkVerbosityAndAdjustAsync()
107 {
108 global $bearsamppConfig;
109
110 try {
111 if (!isset($bearsamppConfig)) {
112 return;
113 }
114
115 $verbosity = $bearsamppConfig->getLogsVerbose();
116
117 // Disable async and reduce buffer size for DEBUG/TRACE logging
118 // This ensures live log monitoring shows logs immediately
119 if ($verbosity === Config::VERBOSE_TRACE) {
120 self::$asyncEnabled = false;
121 // Use smaller buffer for TRACE to flush more frequently (every 10 entries)
122 self::$logBufferSize = 10;
123 } elseif ($verbosity === Config::VERBOSE_DEBUG) {
124 self::$asyncEnabled = false;
125 // For DEBUG level, use moderate buffer size (25 entries)
126 self::$logBufferSize = 25;
127 }
128 // For INFO and REPORT levels, use default buffer size (50) with async enabled
129 } catch (Exception $e) {
130 // Silently fail - this is just an optimization
131 }
132 }
133
139 private static function initializeAsyncQueue()
140 {
141 try {
142 // Set up queue directory in tmp
143 self::$asyncQueueDir = Path::getTmpPath() . '/log-queue';
144
145 // Create queue directory if it doesn't exist
146 if (!is_dir(self::$asyncQueueDir)) {
147 @mkdir(self::$asyncQueueDir, 0755, true);
148 }
149 } catch (Exception $e) {
150 self::$asyncEnabled = false;
151 }
152 }
153
166 private static function write($data, $type, $file = null)
167 {
169
170 // Safety check: if globals aren't initialised, fall back to error_log
171 if (!isset($bearsamppRoot) || !isset($bearsamppCore) || !isset($bearsamppConfig)) {
172 error_log('[' . $type . '] ' . $data);
173
174 return;
175 }
176
177 // Lazily register the shutdown handler if init() was not called explicitly
178 self::init();
179
180 // Resolve default file path only when the caller did not supply one
181 if ($file === null) {
182 $file = $type === self::ERROR
185
186 if (!$bearsamppRoot->isRoot()) {
188 }
189 }
190
191 $verbose = [];
192 $verbose[Config::VERBOSE_SIMPLE] = $type === self::ERROR || $type === self::WARNING;
196
197 $writeLog = false;
199 $writeLog = true;
200 } elseif ($bearsamppConfig->getLogsVerbose() === Config::VERBOSE_REPORT && $verbose[Config::VERBOSE_REPORT]) {
201 $writeLog = true;
202 } elseif ($bearsamppConfig->getLogsVerbose() === Config::VERBOSE_DEBUG && $verbose[Config::VERBOSE_DEBUG]) {
203 $writeLog = true;
204 } elseif ($bearsamppConfig->getLogsVerbose() === Config::VERBOSE_TRACE && $verbose[Config::VERBOSE_TRACE]) {
205 $writeLog = true;
206 }
207
208 if ($writeLog) {
209 // Sanitize line breaks to prevent log forging (injecting fake log lines).
210 self::$logBuffer[] = [
211 'file' => $file,
212 'data' => str_replace(["\r", "\n"], ' ', (string)$data),
213 'type' => $type,
214 'time' => time(),
215 ];
216 self::$logStats['buffered']++;
217
218 // In silent mode, suppress auto-flush so the caller can decide
219 // whether to commit (flush) or rollback (discard) the buffer.
220 // ERROR entries always flush — they must never be silently dropped.
221 if (self::$silentMode && $type !== self::ERROR) {
222 return;
223 }
224
225 // Flush immediately for:
226 // 1. Errors (always)
227 // 2. TRACE/DEBUG level logs (for real-time visibility during debugging)
228 // 3. When buffer reaches the configured size limit
229 $debugVerbosity = $bearsamppConfig->getLogsVerbose();
230 $isDebugMode = ($debugVerbosity === Config::VERBOSE_TRACE || $debugVerbosity === Config::VERBOSE_DEBUG);
231 $shouldFlush = $type === self::ERROR ||
232 $isDebugMode ||
233 count(self::$logBuffer) >= self::$logBufferSize;
234
235 if ($shouldFlush) {
236 self::flush();
237 }
238 }
239 }
240
249 public static function flush()
250 {
251 if (empty(self::$logBuffer)) {
252 return;
253 }
254
256
257 // If the core global is gone (e.g. during an abnormal shutdown), fall back to error_log
258 if (!isset($bearsamppCore)) {
259 foreach (self::$logBuffer as $log) {
260 error_log('[' . date('Y-m-d H:i:s', $log['time']) . '] [' . $log['type'] . '] ' . $log['data']);
261 }
262 self::$logStats['flushed'] += count(self::$logBuffer);
263 self::$logBuffer = [];
264
265 return;
266 }
267
268 // Check if DEBUG or TRACE logging is active and force sync writes
269 $forceSync = false;
270 try {
271 if (isset($bearsamppConfig)) {
272 $verbosity = $bearsamppConfig->getLogsVerbose();
273 if ($verbosity === Config::VERBOSE_TRACE || $verbosity === Config::VERBOSE_DEBUG) {
274 $forceSync = true;
275 }
276 }
277 } catch (Exception $e) {
278 // Ignore errors checking config
279 }
280
281 // Group logs by destination file
282 $logsByFile = [];
283 foreach (self::$logBuffer as $log) {
284 $logsByFile[$log['file']][] = $log;
285 }
286
287 foreach ($logsByFile as $file => $logs) {
288 $content = '';
289 foreach ($logs as $log) {
290 $content .= '[' . date('Y-m-d H:i:s', $log['time']) . '] # ' .
291 APP_TITLE . ' ' . $bearsamppCore->getAppVersion() . ' # ' .
292 $log['type'] . ': ' . $log['data'] . PHP_EOL;
293 }
294
295 // Use sync writes if TRACE is enabled or async is disabled
296 if (!$forceSync && self::$asyncEnabled) {
297 // Queue for async processing (non-blocking)
298 $queued = self::queueAsyncWrite($file, $content);
299
300 if ($queued) {
301 // Successfully queued, content will be written in background
302 self::$logStats['async']++;
303 self::$logStats['writes']++;
304 continue;
305 }
306 // If async queueing failed, fall through to sync write
307 }
308
309 // Synchronous write (immediate for TRACE, fallback for others)
310 $written = @file_put_contents($file, $content, FILE_APPEND | LOCK_EX);
311 if ($written === false) {
312 // File write failed — ensure entries are not silently lost
313 foreach ($logs as $log) {
314 error_log('[' . $log['type'] . '] ' . $log['data'] . ' (target: ' . $file . ')');
315 }
316 }
317
318 self::$logStats['writes']++;
319 }
320
321 self::$logStats['flushed'] += count(self::$logBuffer);
322 self::$logBuffer = [];
323 }
324
334 private static function queueAsyncWrite($file, $content)
335 {
336 if (!self::$asyncEnabled || !is_dir(self::$asyncQueueDir)) {
337 return false;
338 }
339
340 // Defense-in-depth: reject oversized content at the source.
341 if (strlen((string)$content) > self::MAX_QUEUE_CONTENT_LENGTH) {
342 return false;
343 }
344
345 try {
346 // Create a unique queue file for this write
347 $queueFile = self::$asyncQueueDir . '/' . uniqid('log_', true) . '.queue';
348
349 // Queue entry contains the target file and content
350 $queueEntry = [
351 'file' => $file,
352 'content' => $content,
353 'timestamp' => time(),
354 ];
355
356 // Write queue entry (this is a small, fast operation)
357 $serialized = serialize($queueEntry);
358 $written = @file_put_contents($queueFile, $serialized, LOCK_EX);
359
360 return ($written !== false);
361 } catch (Exception $e) {
362 // Silently fail - this is async so we don't want to interrupt main process
363 return false;
364 }
365 }
366
379 private static function sanitizeQueueTarget($file)
380 {
381 $file = (string)$file;
382 if ($file === '') {
383 return null;
384 }
385
386 $normalized = str_replace('\\', '/', $file);
387 if (strpos($normalized, '..') !== false) {
388 return null;
389 }
390
391 $logsPath = Path::getLogsPath();
392 $logsPathNormalized = str_replace('\\', '/', rtrim($logsPath, '/\\')) . '/';
393
394 // Only allow appends into the application logs directory.
395 if (strpos($normalized, $logsPathNormalized) !== 0) {
396 return null;
397 }
398
399 return $file;
400 }
401
410 public static function flushAsyncQueue()
411 {
412 $totalProcessed = 0;
413
414 // processAsyncQueue() processes one bounded batch; loop until nothing is left so the
415 // manual flush and the shutdown path drain the queue completely.
416 $iterationCap = 1000;
417 for ($i = 0; $i < $iterationCap; $i++) {
418 $processed = self::processAsyncQueue();
419 if ($processed <= 0) {
420 break;
421 }
422 $totalProcessed += $processed;
423 }
424
425 return $totalProcessed;
426 }
427
434 public static function processAsyncQueue()
435 {
436 if (!is_dir(self::$asyncQueueDir)) {
437 return 0;
438 }
439
440 $processed = 0;
441
442 try {
443 $queueFiles = glob(self::$asyncQueueDir . '/*.queue');
444 if (!is_array($queueFiles) || empty($queueFiles)) {
445 return 0;
446 }
447
448 // Bound the work performed in one pass: read at most MAX_QUEUE_BATCH files so
449 // a flood of queue entries cannot exhaust memory. Anything beyond the batch,
450 // or deferred by the target cap below, is left on disk for a later pass.
451 $batchFiles = array_slice($queueFiles, 0, self::MAX_QUEUE_BATCH);
452
453 // Group entries by target file
454 $entriesByFile = [];
455
456 foreach ($batchFiles as $queueFile) {
457 try {
458 // Read and deserialize queue entry
459 $serialized = @file_get_contents($queueFile);
460 if ($serialized === false) {
461 continue;
462 }
463
464 $entry = @unserialize($serialized, ['allowed_classes' => false]);
465 if ($entry === false || !isset($entry['file']) || !isset($entry['content'])) {
466 // Invalid queue entry, remove it
467 @unlink($queueFile);
468 continue;
469 }
470
471 // Validate the destination is inside the logs directory and not a path traversal.
472 // Queue files live in a shared tmp dir, so a local attacker could plant a rogue
473 // entry and redirect appends to arbitrary files if we trusted $entry['file'] blindly.
474 $targetFile = self::sanitizeQueueTarget($entry['file']);
475 if ($targetFile === null) {
476 // Invalid/unsafe target - discard the rogue entry
477 @unlink($queueFile);
478 continue;
479 }
480
481 // Cap the distinct target files processed in this pass. Once reached, stop
482 // accumulating and leave this entry (and those after it) untouched so they
483 // are preserved for a later pass rather than being dropped.
484 if (!isset($entriesByFile[$targetFile]) &&
485 count($entriesByFile) >= self::MAX_QUEUE_TARGETS) {
486 break;
487 }
488
489 // Cap the content size to prevent log bloat / disk exhaustion from rogue entries.
490 $content = (string)$entry['content'];
491 if (strlen($content) > self::MAX_QUEUE_CONTENT_LENGTH) {
492 @unlink($queueFile);
493 continue;
494 }
495
496 if (!isset($entriesByFile[$targetFile])) {
497 $entriesByFile[$targetFile] = [];
498 }
499
500 $entriesByFile[$targetFile][] = $content;
501 $processed++;
502
503 // Remove the processed queue file
504 @unlink($queueFile);
505 } catch (Exception $e) {
506 // Skip bad entries
507 @unlink($queueFile);
508 }
509 }
510
511 // Write all accumulated entries to their target files
512 foreach ($entriesByFile as $targetFile => $contents) {
513 try {
514 $combined = implode('', $contents);
515 @file_put_contents($targetFile, $combined, FILE_APPEND | LOCK_EX);
516 } catch (Exception $e) {
517 // Log write failed - use error_log as fallback
518 error_log('Failed to write to ' . $targetFile);
519 }
520 }
521
522 // Clean up any stale queue files
524 } catch (Exception $e) {
525 // Silently fail
526 }
527
528 return $processed;
529 }
530
539 private static function cleanupStaleAsyncQueue($maxAge)
540 {
541 if (!is_dir(self::$asyncQueueDir)) {
542 return 0;
543 }
544
545 $removed = 0;
546 $now = time();
547
548 try {
549 $queueFiles = @glob(self::$asyncQueueDir . '/*.queue');
550
551 if (!is_array($queueFiles)) {
552 return 0;
553 }
554
555 foreach ($queueFiles as $queueFile) {
556 try {
557 // Remove files older than maxAge
558 if ($now - @filemtime($queueFile) > $maxAge) {
559 @unlink($queueFile);
560 $removed++;
561 }
562 } catch (Exception $e) {
563 // Skip
564 }
565 }
566 } catch (Exception $e) {
567 // Ignore cleanup errors
568 }
569
570 return $removed;
571 }
572
579 public static function reset()
580 {
581 self::$logBuffer = [];
582 self::$shutdownRegistered = false;
583 self::$logStats = [
584 'buffered' => 0,
585 'flushed' => 0,
586 'writes' => 0,
587 'async' => 0,
588 ];
589 }
590
598 public static function setAsyncEnabled($enabled)
599 {
600 self::$asyncEnabled = (bool)$enabled;
601 }
602
608 public static function isAsyncEnabled()
609 {
610 return self::$asyncEnabled;
611 }
612
618 public static function getAsyncQueueDir()
619 {
620 return self::$asyncQueueDir;
621 }
622
628 public static function getAsyncQueueSize()
629 {
630 if (!is_dir(self::$asyncQueueDir)) {
631 return 0;
632 }
633
634 $files = @glob(self::$asyncQueueDir . '/*.queue');
635
636 return is_array($files) ? count($files) : 0;
637 }
638
644 public static function getStats()
645 {
646 return self::$logStats;
647 }
648
656 public static function setBufferSize($size)
657 {
658 if ($size > 0 && $size <= 1000) {
659 self::$logBufferSize = $size;
660 }
661 }
662
668 public static function getBufferSize()
669 {
670 return self::$logBufferSize;
671 }
672
678 public static function separator()
679 {
680 if (self::$silentMode) {
681 return;
682 }
683
684 global $bearsamppRoot;
685
686 $logs = [
695 ];
696
697 $separator = '========================================================================================' . PHP_EOL;
698 foreach ($logs as $log) {
699 if (!file_exists($log)) {
700 continue;
701 }
702 $logContent = @file_get_contents($log);
703 if ($logContent !== false && !str_ends_with($logContent, $separator)) {
704 @file_put_contents($log, $separator, FILE_APPEND);
705 }
706 }
707 }
708
715 public static function trace($data, $file = null)
716 {
717 self::write($data, self::TRACE, $file);
718 }
719
726 public static function debug($data, $file = null)
727 {
728 self::write($data, self::DEBUG, $file);
729 }
730
737 public static function info($data, $file = null)
738 {
739 self::write($data, self::INFO, $file);
740 }
741
748 public static function warning($data, $file = null)
749 {
750 self::write($data, self::WARNING, $file);
751 }
752
760 public static function error($data, $file = null)
761 {
762 self::write($data, self::ERROR, $file);
763 }
764
771 private static $initClassCounts = array();
772
774 private static $reloadClassCounts = array();
775
784 public static function initClass($classInstance)
785 {
786 $className = get_class($classInstance);
787
788 if (isset(self::$initClassCounts[$className])) {
789 self::$initClassCounts[$className]++;
790
791 return;
792 }
793
794 self::$initClassCounts[$className] = 1;
795 self::trace('Init ' . $className);
796 }
797
805 public static function reloadClass($classInstance)
806 {
807 $className = get_class($classInstance);
808
809 if (isset(self::$reloadClassCounts[$className])) {
810 self::$reloadClassCounts[$className]++;
811
812 return;
813 }
814
815 self::$reloadClassCounts[$className] = 1;
816 self::trace('Reload ' . $className);
817 }
818
829 public static function startSilentBuffer()
830 {
831 self::$silentMode = true;
832 // Discard anything already buffered from early bootstrap (e.g. separator)
833 self::$logBuffer = [];
834 }
835
841 public static function commitSilentBuffer()
842 {
843 self::$silentMode = false;
844 self::flush();
845 }
846
852 public static function rollbackSilentBuffer()
853 {
854 self::$silentMode = false;
855 self::$logBuffer = [];
856 }
857
863 public static function isSilent()
864 {
865 return self::$silentMode;
866 }
867}
868
global $bearsamppRoot
global $bearsamppCore
const VERBOSE_REPORT
const VERBOSE_SIMPLE
const VERBOSE_TRACE
const VERBOSE_DEBUG
static queueAsyncWrite($file, $content)
const MAX_QUEUE_BATCH
Definition class.log.php:41
const MAX_QUEUE_TARGETS
Definition class.log.php:44
static flush()
static setBufferSize($size)
static $logStats
Definition class.log.php:56
static info($data, $file=null)
static getBufferSize()
static reset()
static getAsyncQueueSize()
const MAX_QUEUE_CONTENT_LENGTH
Definition class.log.php:38
static $asyncQueueDir
Definition class.log.php:67
static startSilentBuffer()
static rollbackSilentBuffer()
static debug($data, $file=null)
static initializeAsyncQueue()
static sanitizeQueueTarget($file)
static isSilent()
static $silentMode
Definition class.log.php:64
static $asyncEnabled
Definition class.log.php:70
static setAsyncEnabled($enabled)
static getStats()
static $logBuffer
Definition class.log.php:47
const DEBUG
Definition class.log.php:34
const ERROR
Definition class.log.php:31
static warning($data, $file=null)
static $logBufferSize
Definition class.log.php:50
static reloadClass($classInstance)
static processAsyncQueue()
static write($data, $type, $file=null)
static $maxAsyncQueueBatch
Definition class.log.php:73
static init()
Definition class.log.php:84
static $shutdownRegistered
Definition class.log.php:53
static trace($data, $file=null)
static checkVerbosityAndAdjustAsync()
static error($data, $file=null)
static getAsyncQueueDir()
static isAsyncEnabled()
const WARNING
Definition class.log.php:32
static flushAsyncQueue()
const TRACE
Definition class.log.php:35
static $initClassCounts
static separator()
static $reloadClassCounts
const INFO
Definition class.log.php:33
static initClass($classInstance)
static cleanupStaleAsyncQueue($maxAge)
static commitSilentBuffer()
static getBatchLogFilePath($aetrayPath=false)
static getLogFilePath($aetrayPath=false)
static getStartupLogFilePath($aetrayPath=false)
static getServicesLogFilePath($aetrayPath=false)
static getLogsPath($aetrayPath=false)
static getRegistryLogFilePath($aetrayPath=false)
static getTmpPath($aetrayPath=false)
static getHomepageLogFilePath($aetrayPath=false)
static getErrorLogFilePath($aetrayPath=false)
static getWinbinderLogFilePath($aetrayPath=false)
global $bearsamppConfig
Definition homepage.php:46
$verbose
Definition pearcmd.php:167
const APP_TITLE
Definition root.php:19