The default log handlers allow date-based rotation, which works fine, but I need to rotate log files based on file size. Unfortunately, the default handlers do not allow size-based rotation.
Background: Add custom handlers to config-internal.php in logger.handlerList according to this page. The configurations shown below are entries into the logger.handlerList array.
Solution: To solve my problem, I created two new handlers:
The RotateBySize handler forces the following behaviors:
Here are the associated files:
custom/Espo/Custom/Log/RotateBySizeLoader.php
custom/Espo/Custom/Log/RotateBySizeHandler.php
custom/Espo/Custom/Log/RotateBySizeAndDateLoader.php
custom/Espo/Custom/Log/RotateBySizeAndDateHandler.php
After several days, the log files will look something like this:
Background: Add custom handlers to config-internal.php in logger.handlerList according to this page. The configurations shown below are entries into the logger.handlerList array.
Solution: To solve my problem, I created two new handlers:
- RotateBySize
- RotateBySizeAndDate
PHP Code:
'handlerList' => array(
[
'className' => 'Espo\\Custom\\Log\\RotateBySizeHandler',
'loaderClassName' => 'Espo\\Custom\\Log\\RotateBySizeLoader',
'params' => [
'filename' => 'data/logs/espo-debug.log',
'maxFileNumber' => 30,
'maxFileSize' => 10 * 1024 * 1024,
],
'level' => 'DEBUG',
'formatter' => [
'className' => 'Espo\\Core\\Log\\DefaultFormatter',
'params' => [
'lineFormat' => "[%datetime%] %level_name%: %code% %message% %request% %context%\n"
]
]
],
[
'className' => 'Espo\\Custom\\Log\\RotateBySizeAndDateHandler',
'loaderClassName' => 'Espo\\Custom\\Log\\RotateBySizeAndDateLoader',
'params' => [
'filename' => 'data/logs/espo-info.log',
'maxDays' => 30,
'maxFilesPerDay' => 20,
'maxFileSize' => 10 * 1024 * 1024,
],
'level' => 'INFO',
'formatter' => [
'className' => 'Espo\\Core\\Log\\DefaultFormatter',
'params' => [
'lineFormat' => "[%datetime%] %level_name%: %code% %message% %request% %context%\n"
]
]
]
)
],
The RotateBySize handler forces the following behaviors:
- File sizes must be at least 10 kB. Negative values or values that are less than 10 kB will cause the default value of 10 kB to be used.
- The maximum file count must be 0 (unlimited files) or positive (limited files). Negative numbers will cause the default value of 30 to be used.
- Log files will be named using the more common format of <filename>.log[.n]. For example, custom-log-file.log.4.
- File sizes must be at least 10 kB. Negative values or values that are less than 10 kB will cause the default value of 10 kB to be used.
- The maximum number of days must be 0 (unlimited days) or positive (limited days). Negative numbers will cause the default value of 30 to be used.
- The maximum number of files per day must be 0 (unlimited files per day) or positive (limited files per day). Negative numbers will cause the default value of 20 to be used.
- Log files will be named using the more common format of <filename>-<date>.log[.n]. For example, custom-log-file-2024-07-31.log.4
Here are the associated files:
custom/Espo/Custom/Log/RotateBySizeLoader.php
PHP Code:
<?php
namespace Espo\Custom\Log;
use Espo\Custom\Log\RotateBySizeHandler;
use Espo\Core\Utils\Config;
use Monolog\Handler\HandlerInterface;
use Monolog\Level;
use Monolog\Logger;
class RotateBySizeLoader implements \Espo\Core\Log\HandlerLoader
{
public function __construct(
private readonly Config $config
) { }
public function load(array $params): HandlerInterface
{
$filename = $params['filename'] ?? 'data/logs/espo.log';
$levelCode = $params['level'] ?? Level::Notice->value;
$maxFileNumber = $params['maxFileNumber'] ?? 30;
$maxFileSize = $params['maxFileSize'] ?? 10 * 1024 * 1024; # 1 MB
$level = Logger::toMonologLevel($levelCode);
return new RotateBySizeHandler($this->config, $filename, $maxFileNumber, $maxFileSize, $level);
}
}
custom/Espo/Custom/Log/RotateBySizeHandler.php
PHP Code:
<?php
namespace Espo\Custom\Log;
use Espo\Core\Utils\Config;
use Monolog\Level;
class RotateBySizeHandler extends \Espo\Core\Log\Handler\EspoFileHandler
{
protected string $filename;
protected int $maxFileNumber;
protected int $maxFileSize;
public function __construct(
Config $config,
string $filename,
int $maxFileNumber,
int $maxFileSize,
Level $level = Level::Debug,
bool $bubble = true
) {
$this->filename = $filename;
$this->maxFileNumber = $maxFileNumber > -1 ? $maxFileNumber : 30;
$this->maxFileSize = $maxFileSize > 10 * 1024 ? $maxFileSize : 10 * 1024;
parent::__construct($config, $this->filename, $level, $bubble);
$this->rotate();
}
protected function rotate(): void {
$filePattern = $this->getFilePattern();
$dirPath = $this->fileManager->getDirName($this->filename);
$logFiles = $this->fileManager->getFileList($dirPath, false, $filePattern, true);
if( empty($logFiles) ||
!file_exists($this->filename) ||
(filesize($this->filename) < $this->maxFileSize))
return;
usort($logFiles, 'strnatcmp');
$pattern = "{$this->filename}.%d";
for($i = count($logFiles) - 1; $i > 0; $i--) {
$x = array($logFiles[$i], $i, sprintf($pattern, $i + 1));
rename("{$dirPath}/{$logFiles[$i]}", sprintf($pattern, $i + 1));
}
rename("{$dirPath}/{$logFiles[0]}", sprintf($pattern, 1));
if($this->maxFileNumber == 0) return;
$logFiles = $this->fileManager->getFileList($dirPath, false, $filePattern, true);
usort($logFiles, 'strnatcmp');
$logFilesToBeRemoved = array_slice($logFiles, $this->maxFileNumber - 1);
$this->fileManager->removeFile($logFilesToBeRemoved, $dirPath);
}
protected function getFilePattern(): string {
$fileInfo = pathinfo($this->filename);
return "^{$fileInfo['basename']}(\.\d+)?$";
}
}
custom/Espo/Custom/Log/RotateBySizeAndDateLoader.php
PHP Code:
<?php
namespace Espo\Custom\Log;
use Espo\Custom\Log\RotateBySizeAndDateHandler;
use Espo\Core\Utils\Config;
use Monolog\Handler\HandlerInterface;
use Monolog\Level;
use Monolog\Logger;
class RotateBySizeAndDateLoader implements \Espo\Core\Log\HandlerLoader
{
public function __construct(
private readonly Config $config
) { }
public function load(array $params): HandlerInterface
{
$filename = $params['filename'] ?? 'data/logs/espo.log';
$levelCode = $params['level'] ?? Level::Notice->value;
$maxDays = $params['maxDays'] ?? 30;
$maxFilesPerDay = $params['maxFilesPerDay'] ?? 20;
$maxFileSize = $params['maxFileSize'] ?? 10 * 1024 * 1024; # 10 MB
$level = Logger::toMonologLevel($levelCode);
return new RotateBySizeAndDateHandler($this->config, $filename, $maxDays, $maxFilesPerDay, $maxFileSize, $level);
}
}
custom/Espo/Custom/Log/RotateBySizeAndDateHandler.php
PHP Code:
<?php
namespace Espo\Custom\Log;
use Espo\Core\Utils\Config;
use Monolog\Level;
use DateTime;
class RotateBySizeAndDateHandler extends \Espo\Core\Log\Handler\EspoFileHandler
{
protected string $dateFormat = 'Y-m-d';
protected string $filenameFormat = '{filename}-{date}';
protected string $filename;
protected int $maxDays;
protected int $maxFilesPerDay;
protected int $maxFileSize;
public function __construct(
Config $config,
string $filename,
int $maxDays,
int $maxFilesPerDay,
int $maxFileSize,
Level $level = Level::Debug,
bool $bubble = true
) {
$this->filename = $filename;
$this->maxDays = $maxDays;
$this->maxFilesPerDay = $maxFilesPerDay;
$this->maxFileSize = $maxFileSize;
$this->maxDays = $maxDays > -1 ? $maxDays : 30;
$this->maxFilesPerDay = $maxFilesPerDay > -1 ? $maxFilesPerDay : 20;
$this->maxFileSize = $maxFileSize; # > 10 * 1024 ? $maxFileSize : 10 * 1024;
parent::__construct($config, $this->getTimedFilename(date($this->dateFormat)), $level, $bubble);
$this->rotate();
$this->removeOldLogs();
}
protected function rotate(): void {
$filePattern = $this->getFilePattern(date($this->dateFormat));
$filename = $this->getTimedFilename(date($this->dateFormat));
$dirPath = $this->fileManager->getDirName($filename);
$logFiles = $this->fileManager->getFileList($dirPath, false, $filePattern, true);
if( empty($logFiles) ||
!file_exists($filename) ||
(filesize($filename) < $this->maxFileSize))
return;
usort($logFiles, 'strnatcmp');
$pattern = "{$filename}.%d";
for($i = count($logFiles) - 1; $i > 0; $i--) {
$x = array($logFiles[$i], $i, sprintf($pattern, $i + 1));
rename("{$dirPath}/{$logFiles[$i]}", sprintf($pattern, $i + 1));
}
rename("{$dirPath}/{$logFiles[0]}", sprintf($pattern, 1));
if($this->maxFilesPerDay == 0) return;
$logFiles = $this->fileManager->getFileList($dirPath, false, $filePattern, true);
usort($logFiles, 'strnatcmp');
$logFilesToBeRemoved = array_slice($logFiles, $this->maxFilesPerDay - 1);
$this->fileManager->removeFile($logFilesToBeRemoved, $dirPath);
}
protected function removeOldLogs(): void {
if($this->maxDays == 0) return;
$now = new DateTime();
$filePattern = $this->getFilePattern();
$dirPath = $this->fileManager->getDirName($this->filename);
$logFiles = $this->fileManager->getFileList($dirPath, false, $filePattern, true);
usort($logFiles, 'strnatcmp');
$filenameRoot = pathinfo($this->filename)["filename"] . "-";
$logFilesToBeRemoved = array();
foreach($logFiles as $logFile) {
$fileInfo = pathinfo($logFile);
$parts = explode($filenameRoot, $logFile);
$date = new DateTime(substr($parts[1], 0, 10));
if($date->diff($now)->days >= $this->maxDays) {
$logFilesToBeRemoved[] = $logFile;
}
}
$this->fileManager->removeFile($logFilesToBeRemoved, $dirPath);
}
protected function getTimedFilename($date): string {
$fileInfo = pathinfo($this->filename);
$timedFilename = str_replace(
['{filename}', '{date}'],
[$fileInfo['filename'], $date],
($fileInfo['dirname'] ?? '') . '/' . $this->filenameFormat
);
if (!empty($fileInfo['extension'])) {
$timedFilename .= '.' . $fileInfo['extension'];
}
return $timedFilename;
}
protected function getFilePattern($date = ".*"): string {
$fileInfo = pathinfo($this->filename);
$glob = str_replace(
['{filename}', '{date}'],
[$fileInfo['filename'], $date],
$this->filenameFormat
);
if (!empty($fileInfo['extension'])) {
$glob .= '\.'.$fileInfo['extension'];
}
$glob .= "(\.\d+)?";
return '^' . $glob . '$';
}
}
After several days, the log files will look something like this: