77use Monolog \Logger ;
88use Monolog \Handler \StreamHandler ;
99use Monolog \Handler \RotatingFileHandler ;
10+ use Monolog \Handler \ErrorLogHandler ;
1011
1112final class LoggerUtility
1213{
1314 private static ?Logger $ logger = null ;
1415 private const LOG_FILENAME = 'logfile.log ' ;
1516 private const LOG_ROTATIONS = 30 ;
17+ private const MAX_FILE_SIZE_MB = 100 ; // Maximum size per log file in MB
18+ private const MAX_TOTAL_LOG_SIZE_MB = 1000 ; // Maximum total size for all logs in MB
19+ private const MAX_MESSAGE_LENGTH = 10000 ; // Maximum length for a single log message
20+ private static int $ logCallCount = 0 ;
21+ private static int $ maxLogsPerRequest = 10000 ; // Prevent infinite logging loops
22+ private static bool $ hasLoggedFallback = false ; // Prevent recursive fallback logging
1623
1724 public static function getLogger (): Logger
1825 {
@@ -25,11 +32,34 @@ public static function getLogger(): Logger
2532 $ logLevel = defined ('LOG_LEVEL ' ) ? self ::parseLogLevel (LOG_LEVEL ) : Level::Debug;
2633
2734 try {
35+ // Check total log directory size before proceeding
36+ if (is_dir ($ logDir ) && !self ::checkLogDirectorySize ($ logDir )) {
37+ self ::useFallbackHandler ("Log directory exceeded maximum size limit " );
38+ return self ::$ logger ;
39+ }
40+
2841 if (MiscUtility::makeDirectory ($ logDir , 0775 )) {
2942 if (is_writable ($ logDir )) {
3043 $ logPath = $ logDir . '/ ' . self ::LOG_FILENAME ;
31- $ handler = new RotatingFileHandler ($ logPath , self ::LOG_ROTATIONS , $ logLevel );
44+
45+ // Check if current log file is too large
46+ if (file_exists ($ logPath ) && filesize ($ logPath ) > self ::MAX_FILE_SIZE_MB * 1024 * 1024 ) {
47+ // Force rotation by renaming the file
48+ $ backupName = $ logDir . '/ ' . date ('Y-m-d ' ) . '- ' . self ::LOG_FILENAME ;
49+ @rename ($ logPath , $ backupName );
50+ }
51+
52+ // Use RotatingFileHandler
53+ $ handler = new RotatingFileHandler (
54+ $ logPath ,
55+ self ::LOG_ROTATIONS ,
56+ $ logLevel ,
57+ true ,
58+ 0664 ,
59+ false
60+ );
3261 $ handler ->setFilenameFormat ('{date}-{filename} ' , 'Y-m-d ' );
62+
3363 self ::$ logger ->pushHandler ($ handler );
3464 } else {
3565 self ::useFallbackHandler ("Log directory not writable: $ logDir " );
@@ -38,17 +68,130 @@ public static function getLogger(): Logger
3868 self ::useFallbackHandler ("Failed to create log directory: $ logDir " );
3969 }
4070 } catch (Throwable $ e ) {
41- self ::useFallbackHandler ($ e ->getMessage ());
71+ self ::useFallbackHandler ("Exception during logger setup: " . $ e ->getMessage ());
72+ }
73+
74+ // CRITICAL: Ensure we always have at least one handler to prevent crashes
75+ if (empty (self ::$ logger ->getHandlers ())) {
76+ self ::useErrorLogHandler ("No handlers were successfully configured - falling back to PHP error_log " );
4277 }
4378
4479 return self ::$ logger ;
4580 }
4681
82+ private static function checkLogDirectorySize (string $ logDir ): bool
83+ {
84+ try {
85+ $ totalSize = 0 ;
86+ $ maxSize = self ::MAX_TOTAL_LOG_SIZE_MB * 1024 * 1024 ;
87+
88+ $ iterator = new \RecursiveIteratorIterator (
89+ new \RecursiveDirectoryIterator ($ logDir , \FilesystemIterator::SKIP_DOTS ),
90+ \RecursiveIteratorIterator::SELF_FIRST
91+ );
92+
93+ foreach ($ iterator as $ file ) {
94+ if ($ file ->isFile ()) {
95+ $ totalSize += $ file ->getSize ();
96+
97+ // Early exit if we've exceeded the limit
98+ if ($ totalSize > $ maxSize ) {
99+ self ::logToPhpErrorLog ("WARNING: Log directory size exceeded {$ maxSize } bytes. Cleaning up old logs... " );
100+ self ::cleanupOldLogs ($ logDir );
101+ return false ;
102+ }
103+ }
104+ }
105+
106+ return true ;
107+ } catch (Throwable $ e ) {
108+ self ::logToPhpErrorLog ("Failed to check log directory size: {$ e ->getMessage ()}" );
109+ return true ; // Allow logging to continue on error
110+ }
111+ }
112+
113+ private static function cleanupOldLogs (string $ logDir ): void
114+ {
115+ try {
116+ $ files = [];
117+ $ iterator = new \RecursiveIteratorIterator (
118+ new \RecursiveDirectoryIterator ($ logDir , \FilesystemIterator::SKIP_DOTS )
119+ );
120+
121+ foreach ($ iterator as $ file ) {
122+ if ($ file ->isFile () && preg_match ('/\.log$/ ' , $ file ->getFilename ())) {
123+ $ files [] = [
124+ 'path ' => $ file ->getRealPath (),
125+ 'mtime ' => $ file ->getMTime (),
126+ 'size ' => $ file ->getSize ()
127+ ];
128+ }
129+ }
130+
131+ // Sort by modification time (oldest first)
132+ usort ($ files , fn ($ a , $ b ) => $ a ['mtime ' ] <=> $ b ['mtime ' ]);
133+
134+ // Delete oldest files until we're under 80% of the limit
135+ $ targetSize = self ::MAX_TOTAL_LOG_SIZE_MB * 1024 * 1024 * 0.8 ;
136+ $ currentSize = array_sum (array_column ($ files , 'size ' ));
137+
138+ foreach ($ files as $ file ) {
139+ if ($ currentSize <= $ targetSize ) {
140+ break ;
141+ }
142+
143+ if (@unlink ($ file ['path ' ])) {
144+ $ currentSize -= $ file ['size ' ];
145+ self ::logToPhpErrorLog ("Deleted old log file: {$ file ['path ' ]}" );
146+ }
147+ }
148+ } catch (Throwable $ e ) {
149+ self ::logToPhpErrorLog ("Failed to cleanup old logs: {$ e ->getMessage ()}" );
150+ }
151+ }
152+
47153 private static function useFallbackHandler (string $ reason ): void
48154 {
49- $ fallbackHandler = new StreamHandler ('php://stderr ' , Level::Warning);
50- self ::$ logger ->pushHandler ($ fallbackHandler );
51- error_log ("LoggerUtility fallback: {$ reason } | PHP error_log: " . self ::getPhpErrorLogPath ());
155+ try {
156+ // Try to use stderr as fallback
157+ $ fallbackHandler = new StreamHandler ('php://stderr ' , Level::Warning);
158+ self ::$ logger ->pushHandler ($ fallbackHandler );
159+ self ::logToPhpErrorLog ("LoggerUtility fallback to stderr: {$ reason }" );
160+ } catch (Throwable $ e ) {
161+ // If even stderr fails, use PHP's error_log
162+ self ::useErrorLogHandler ("Stderr fallback failed: {$ e ->getMessage ()} | Original reason: {$ reason }" );
163+ }
164+ }
165+
166+ private static function useErrorLogHandler (string $ reason ): void
167+ {
168+ try {
169+ // ErrorLogHandler uses PHP's error_log() function
170+ $ errorLogHandler = new ErrorLogHandler (ErrorLogHandler::OPERATING_SYSTEM , Level::Warning);
171+ self ::$ logger ->pushHandler ($ errorLogHandler );
172+ self ::logToPhpErrorLog ("LoggerUtility using PHP error_log handler: {$ reason }" );
173+ } catch (Throwable $ e ) {
174+ // Last resort - nothing we can do, just exit gracefully
175+ self ::logToPhpErrorLog ("CRITICAL: All logging handlers failed: {$ e ->getMessage ()} - logging disabled " );
176+ }
177+ }
178+
179+ /**
180+ * Safe logging to PHP's error_log to avoid recursion
181+ */
182+ private static function logToPhpErrorLog (string $ message ): void
183+ {
184+ if (self ::$ hasLoggedFallback ) {
185+ return ; // Prevent spam
186+ }
187+
188+ self ::$ hasLoggedFallback = true ;
189+ @error_log ("LoggerUtility: {$ message } | PHP error_log: " . self ::getPhpErrorLogPath ());
190+
191+ // Reset flag after a moment to allow future errors
192+ register_shutdown_function (function () {
193+ self ::$ hasLoggedFallback = false ;
194+ });
52195 }
53196
54197 public static function getPhpErrorLogPath (): string
@@ -68,40 +211,102 @@ private static function getCallerInfo(int $index = 1): array
68211 public static function log (Level |string $ level , string $ message , array $ context = []): void
69212 {
70213 try {
214+ // Prevent infinite logging loops
215+ self ::$ logCallCount ++;
216+ if (self ::$ logCallCount > self ::$ maxLogsPerRequest ) {
217+ self ::logToPhpErrorLog ("WARNING: Maximum logs per request exceeded. Possible logging loop detected. " );
218+ return ;
219+ }
220+
221+ // Truncate message if too long
222+ if (strlen ($ message ) > self ::MAX_MESSAGE_LENGTH ) {
223+ $ message = substr ($ message , 0 , self ::MAX_MESSAGE_LENGTH ) . '... [message truncated - exceeded ' . self ::MAX_MESSAGE_LENGTH . ' chars] ' ;
224+ }
225+
71226 $ logger = self ::getLogger ();
72227 $ callerInfo = self ::getCallerInfo (1 );
73228 $ context ['file ' ] ??= $ callerInfo ['file ' ];
74229 $ context ['line ' ] ??= $ callerInfo ['line ' ];
75230
76- // Only sanitize in non-debug mode
77- if (
78- defined ('LOG_LEVEL ' ) && strtoupper (LOG_LEVEL ) !== 'DEBUG ' &&
79- (defined ('APPLICATION_ENV ' ) && APPLICATION_ENV !== 'development ' )
80- ) {
81- $ maxContextLength = 1000 ;
82- foreach ($ context as $ key => $ value ) {
83- if (is_string ($ value ) && strlen ($ value ) > $ maxContextLength ) {
84- $ context [$ key ] = substr ($ value , 0 , $ maxContextLength ) . '... [truncated] ' ;
85- }
231+ // Sanitize context data
232+ $ context = self ::sanitizeContext ($ context );
86233
87- if ($ key === 'trace ' ) {
88- if (is_string ($ value ) && strlen ($ value ) > $ maxContextLength ) {
89- $ context [$ key ] = substr ($ value , 0 , $ maxContextLength ) . '... [truncated] ' ;
90- } elseif (is_array ($ value )) {
91- $ context [$ key ] = array_slice ($ value , 0 , 10 );
92- }
93- }
234+ $ logger ->log ($ level , MiscUtility::toUtf8 ($ message ), $ context );
235+ } catch (Throwable $ e ) {
236+ // CRITICAL: Never let logging crash the application
237+ self ::logToPhpErrorLog ("LoggerUtility::log() failed: {$ e ->getMessage ()} | Original message: " . substr ($ message , 0 , 200 ));
238+ }
239+ }
94240
95- if (is_object ($ value ) || is_resource ($ value )) {
96- $ context [$ key ] = '[omitted: ' . gettype ($ value ) . '] ' ;
97- }
241+ private static function sanitizeContext (array $ context ): array
242+ {
243+ $ maxContextLength = 1000 ;
244+ $ maxArrayDepth = 5 ;
245+
246+ // Always sanitize, regardless of environment (safer default)
247+ $ sanitized = [];
248+
249+ foreach ($ context as $ key => $ value ) {
250+ // Skip if key is too long
251+ if (is_string ($ key ) && strlen ($ key ) > 100 ) {
252+ continue ;
253+ }
254+
255+ // Handle different types
256+ if (is_string ($ value )) {
257+ if (strlen ($ value ) > $ maxContextLength ) {
258+ $ sanitized [$ key ] = substr ($ value , 0 , $ maxContextLength ) . '... [truncated] ' ;
259+ } else {
260+ $ sanitized [$ key ] = $ value ;
98261 }
262+ } elseif (is_array ($ value )) {
263+ if ($ key === 'trace ' ) {
264+ // Limit trace depth
265+ $ sanitized [$ key ] = array_slice ($ value , 0 , 10 );
266+ } else {
267+ // Recursively sanitize arrays but limit depth
268+ $ sanitized [$ key ] = self ::sanitizeArrayRecursive ($ value , 0 , $ maxArrayDepth );
269+ }
270+ } elseif (is_object ($ value ) || is_resource ($ value )) {
271+ $ sanitized [$ key ] = '[omitted: ' . gettype ($ value ) . '] ' ;
272+ } elseif (is_scalar ($ value ) || is_null ($ value )) {
273+ $ sanitized [$ key ] = $ value ;
274+ } else {
275+ $ sanitized [$ key ] = '[omitted: ' . gettype ($ value ) . '] ' ;
99276 }
277+ }
100278
101- $ logger ->log ($ level , MiscUtility::toUtf8 ($ message ), $ context );
102- } catch (Throwable $ e ) {
103- error_log ("LoggerUtility failed: {$ e ->getMessage ()} | Original message: {$ message }" );
279+ return $ sanitized ;
280+ }
281+
282+ private static function sanitizeArrayRecursive (array $ array , int $ depth , int $ maxDepth ): array
283+ {
284+ if ($ depth >= $ maxDepth ) {
285+ return ['[max depth reached] ' ];
286+ }
287+
288+ $ sanitized = [];
289+ $ count = 0 ;
290+ $ maxItems = 50 ; // Limit array items
291+
292+ foreach ($ array as $ key => $ value ) {
293+ if ($ count ++ >= $ maxItems ) {
294+ $ sanitized ['... ' ] = '[truncated - max items reached] ' ;
295+ break ;
296+ }
297+
298+ if (is_array ($ value )) {
299+ $ sanitized [$ key ] = self ::sanitizeArrayRecursive ($ value , $ depth + 1 , $ maxDepth );
300+ } elseif (is_string ($ value ) && strlen ($ value ) > 500 ) {
301+ $ sanitized [$ key ] = substr ($ value , 0 , 500 ) . '... [truncated] ' ;
302+ } elseif (is_object ($ value ) || is_resource ($ value )) {
303+ $ sanitized [$ key ] = '[ ' . gettype ($ value ) . '] ' ;
304+ } else {
305+ $ sanitized [$ key ] = $ value ;
306+ }
104307 }
308+
309+ return $ sanitized ;
105310 }
106311
107312 public static function logDebug (string $ message , array $ context = []): void
@@ -134,4 +339,51 @@ private static function parseLogLevel(string $level): Level
134339 default => Level::Debug
135340 };
136341 }
342+
343+ /**
344+ * Reset the log call counter (useful for testing or long-running processes)
345+ */
346+ public static function resetLogCallCount (): void
347+ {
348+ self ::$ logCallCount = 0 ;
349+ }
350+
351+ /**
352+ * Get current log statistics
353+ */
354+ public static function getLogStats (): array
355+ {
356+ $ logDir = defined ('LOG_PATH ' ) ? LOG_PATH : ROOT_PATH . '/logs ' ;
357+ $ stats = [
358+ 'log_calls_this_request ' => self ::$ logCallCount ,
359+ 'log_directory ' => $ logDir ,
360+ 'total_size_mb ' => 0 ,
361+ 'file_count ' => 0
362+ ];
363+
364+ try {
365+ if (is_dir ($ logDir )) {
366+ $ totalSize = 0 ;
367+ $ fileCount = 0 ;
368+
369+ $ iterator = new \RecursiveIteratorIterator (
370+ new \RecursiveDirectoryIterator ($ logDir , \FilesystemIterator::SKIP_DOTS )
371+ );
372+
373+ foreach ($ iterator as $ file ) {
374+ if ($ file ->isFile ()) {
375+ $ totalSize += $ file ->getSize ();
376+ $ fileCount ++;
377+ }
378+ }
379+
380+ $ stats ['total_size_mb ' ] = round ($ totalSize / (1024 * 1024 ), 2 );
381+ $ stats ['file_count ' ] = $ fileCount ;
382+ }
383+ } catch (Throwable $ e ) {
384+ $ stats ['error ' ] = $ e ->getMessage ();
385+ }
386+
387+ return $ stats ;
388+ }
137389}
0 commit comments