diff --git a/README.md b/README.md index dd89a94..68654d9 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ Examples: * `log/dk-{Y}-{m}-{d}.log` will write every day to a different file (eg: `log/dk-2021-01-01.log`) * `log/dk-{Y}-{W}.log` will write every week to a different file (eg: `log/dk-2021-10.log`) -The full list of format specifiers is available [here](https://www.php.net/manual/en/datetime.format.php). +The full list of format specifiers is available in the [official documentation](https://www.php.net/manual/en/datetime.format.php). ## Filtering log messages diff --git a/docs/book/v5/configuring-writer.md b/docs/book/v5/configuring-writer.md index 90ff424..95915aa 100644 --- a/docs/book/v5/configuring-writer.md +++ b/docs/book/v5/configuring-writer.md @@ -23,6 +23,7 @@ return [ 'level' => \Dot\Log\Logger::ALERT, // this is equal to 1 'options' => [ 'stream' => __DIR__ . '/../../log/dk.log', + 'log_lifetime' => null, // OPTIONAL ], ], ], @@ -41,3 +42,15 @@ It is a way to organize writers. The `level` key is optional. The key `stream` is required only if writing into streams/files. + +The `options.log_lifetime` key is optional. + +## Automatic Log File Deletion + +The `options.log_lifetime` key specifies a date after which the specific `writer`'s log files will be **deleted even if not empty**. + +The key can be omitted completely or set to `null` in order to skip this feature. + +To make use of automatic log file deletion, set the value to the number of **days** after which a log file should be deleted. + +> This feature will delete all relevant log files for the configured `writer`, take care not to misconfigure it in a production environment! diff --git a/log.global.php.dist b/log.global.php.dist index ddc18b7..2f883d0 100644 --- a/log.global.php.dist +++ b/log.global.php.dist @@ -18,6 +18,9 @@ return [ 'name' => 'MyFilter', ], ], + // optional key that can be omitted or set to null to disable auto-deletion of logs + // accepts an integer representing the number of days after which a log will be deleted + 'log_lifetime' => null, ], ], ], diff --git a/src/Factory/LoggerAbstractServiceFactory.php b/src/Factory/LoggerAbstractServiceFactory.php index 4355a74..30d7ad5 100644 --- a/src/Factory/LoggerAbstractServiceFactory.php +++ b/src/Factory/LoggerAbstractServiceFactory.php @@ -15,6 +15,7 @@ use function date; use function explode; use function is_array; +use function pathinfo; use function preg_match_all; use function str_replace; @@ -103,7 +104,9 @@ protected function processConfig(array &$config, ContainerInterface $services): if (isset($config['writers'])) { foreach ($config['writers'] as $index => $writerConfig) { if (! empty($writerConfig['options']['stream'])) { - $config['writers'][$index]['options']['stream'] = self::parseVariables( + $config['writers'][$index]['options']['stream_format'] = + pathinfo($writerConfig['options']['stream'])['basename']; + $config['writers'][$index]['options']['stream'] = self::parseVariables( $writerConfig['options']['stream'] ); } diff --git a/src/Writer/Stream.php b/src/Writer/Stream.php index 7070c32..abd7723 100644 --- a/src/Writer/Stream.php +++ b/src/Writer/Stream.php @@ -4,29 +4,45 @@ namespace Dot\Log\Writer; +use DateTimeImmutable; use Dot\Log\Exception\InvalidArgumentException; use Dot\Log\Exception\RuntimeException; use ErrorException; +use Exception; use Laminas\Stdlib\ErrorHandler; use Psr\Container\ContainerExceptionInterface; use Traversable; use function chmod; use function dirname; +use function error_log; use function fclose; use function file_exists; +use function filemtime; use function fopen; use function fwrite; use function get_resource_type; use function gettype; use function is_array; +use function is_dir; +use function is_link; +use function is_numeric; use function is_resource; use function is_string; use function is_writable; use function iterator_to_array; +use function pathinfo; +use function preg_grep; +use function preg_match_all; +use function scandir; use function sprintf; +use function str_replace; +use function stream_get_meta_data; +use function time; use function touch; +use function unlink; +use const PATHINFO_DIRNAME; use const PHP_EOL; class Stream extends AbstractWriter @@ -41,6 +57,10 @@ class Stream extends AbstractWriter */ protected mixed $stream; + protected ?int $logLifetime = null; + + protected ?string $streamFormat = null; + /** * @throws ContainerExceptionInterface * @throws ErrorException @@ -51,12 +71,16 @@ public function __construct( ?string $logSeparator = null, ?int $filePermissions = null ) { + $logLifetime = null; + $streamFormat = null; + if ($streamOrUrl instanceof Traversable) { $streamOrUrl = iterator_to_array($streamOrUrl); } - if (is_array($streamOrUrl)) { parent::__construct($streamOrUrl); + $logLifetime = $streamOrUrl['log_lifetime'] ?? null; + $streamFormat = $streamOrUrl['stream_format'] ?? null; $mode = $streamOrUrl['mode'] ?? null; $logSeparator = $streamOrUrl['log_separator'] ?? null; $filePermissions = $streamOrUrl['chmod'] ?? $filePermissions; @@ -113,6 +137,25 @@ public function __construct( if (null !== $logSeparator) { $this->setLogSeparator($logSeparator); } + + if (is_numeric($logLifetime)) { + $logLifetime = ($logLifetime > 0 ? '-' . $logLifetime : $logLifetime) . ' days'; + + try { + $logLifetime = (new DateTimeImmutable($logLifetime))->getTimestamp(); + if ($logLifetime >= time()) { + $logLifetime = null; + } + } catch (Exception $e) { + $logLifetime = null; + } + + if ($logLifetime !== null && $streamFormat !== null) { + $this->streamFormat = $streamFormat; + } + + $this->logLifetime = $logLifetime; + } } /** @@ -146,8 +189,57 @@ public function getLogSeparator(): string */ public function shutdown(): void { + if ($this->logLifetime !== null && $this->streamFormat !== null) { + $this->removeOldLogs(); + } + if (is_resource($this->stream)) { fclose($this->stream); } } + + protected function removeOldLogs(): void + { + preg_match_all('/{([a-z])}/i', $this->streamFormat, $matches); + + if (! empty($matches[1])) { + foreach ($matches[1] as $match) { + $this->streamFormat = str_replace('{' . $match . '}', '\d+', $this->streamFormat); + } + + $this->streamFormat = str_replace('.', '\.', $this->streamFormat); + } + $streamData = stream_get_meta_data($this->stream); + + $path = $streamData['uri']; + $directory = pathinfo($path, PATHINFO_DIRNAME); + + if (! is_dir($directory)) { + error_log("{$directory} is not a directory"); + return; + } + + $files = scandir($directory) ?: []; + $matches = preg_grep('/^' . $this->streamFormat . '$/', $files) ?: []; + + foreach ($matches as $match) { + $match = sprintf('%s/%s', $directory, $match); + + if (is_link($match)) { + continue; + } + + $fileTimestamp = filemtime($match); + + if ( + $fileTimestamp !== false + && $fileTimestamp < $this->logLifetime + && is_writable($directory) + ) { + if (! @unlink($match)) { + error_log("{$match} could not be deleted"); + } + } + } + } }