From d1e3b4516ec957c8988f9029dac28d59be860706 Mon Sep 17 00:00:00 2001 From: Jurj-Bogdan Date: Fri, 10 Oct 2025 15:11:25 +0300 Subject: [PATCH 1/3] initial version of autodeletion feature Signed-off-by: Jurj-Bogdan --- README.md | 2 +- docs/book/v5/configuring-writer.md | 28 +++++++ log.global.php.dist | 1 + src/Factory/LoggerAbstractServiceFactory.php | 5 +- src/Writer/Stream.php | 80 +++++++++++++++++++- 5 files changed, 112 insertions(+), 4 deletions(-) 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..8a4d6e9 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,30 @@ 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 an acceptable `DateTimeImmutable` [date/time string](https://www.php.net/manual/en/datetime.formats.php) +or an integer representing the number of **days**. + +> Future dates are ignored. + +Examples of date formats: + +```php +'log_lifetime' => null, // feature disabled +'log_lifetime' => 90, // will be converted to `-90 days` so the date is set in the past +'log_lifetime' => "-90", // numeric strings are also accepted and will follow same rules as integers +'log_lifetime' => "30 minutes ago", // valid date +'log_lifetime' => "last monday", // valid date +'log_lifetime' => "monday", // future date will be ignored +'log_lifetime' => "next month", // future date will be ignored +``` + +> 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..0c5c0e6 100644 --- a/log.global.php.dist +++ b/log.global.php.dist @@ -18,6 +18,7 @@ return [ 'name' => 'MyFilter', ], ], + 'log_lifetime' => null, // optional ], ], ], 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..e6abbfb 100644 --- a/src/Writer/Stream.php +++ b/src/Writer/Stream.php @@ -4,9 +4,11 @@ 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; @@ -15,17 +17,27 @@ use function dirname; 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_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 PHP_EOL; @@ -41,6 +53,10 @@ class Stream extends AbstractWriter */ protected mixed $stream; + protected ?int $logLifetime = null; + + protected ?string $streamFormat = null; + /** * @throws ContainerExceptionInterface * @throws ErrorException @@ -49,14 +65,17 @@ public function __construct( mixed $streamOrUrl, ?string $mode = null, ?string $logSeparator = null, - ?int $filePermissions = null + ?int $filePermissions = null, + ?string $logLifetime = null, + ?string $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 +132,27 @@ public function __construct( if (null !== $logSeparator) { $this->setLogSeparator($logSeparator); } + + if (null !== $logLifetime) { + 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 +186,44 @@ 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']; + $uriParts = pathinfo($path); + + $files = scandir($uriParts['dirname']); + $matches = preg_grep('/^' . $this->streamFormat . '$/', $files); + + foreach ($matches as $match) { + $match = sprintf('%s/%s', $uriParts['dirname'], $match); + $fileTimestamp = filemtime($match); + + if ( + $fileTimestamp !== false + && $fileTimestamp < $this->logLifetime + ) { + unlink($match); + } + } + } } From c4c373265bb0febf226c30621158dc233dd7dd89 Mon Sep 17 00:00:00 2001 From: Jurj-Bogdan Date: Tue, 14 Oct 2025 12:05:40 +0300 Subject: [PATCH 2/3] accept days only, more safeguards for deletion Signed-off-by: Jurj-Bogdan --- docs/book/v5/configuring-writer.md | 9 ++----- src/Writer/Stream.php | 42 +++++++++++++++++++++--------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/docs/book/v5/configuring-writer.md b/docs/book/v5/configuring-writer.md index 8a4d6e9..68189bc 100644 --- a/docs/book/v5/configuring-writer.md +++ b/docs/book/v5/configuring-writer.md @@ -51,21 +51,16 @@ The `options.log_lifetime` key specifies a date after which the specific `writer 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 an acceptable `DateTimeImmutable` [date/time string](https://www.php.net/manual/en/datetime.formats.php) -or an integer representing the number of **days**. +To make use of automatic log file deletion, set the value to the number of **days** after which a log file should be deleted. > Future dates are ignored. -Examples of date formats: +Examples of values: ```php 'log_lifetime' => null, // feature disabled 'log_lifetime' => 90, // will be converted to `-90 days` so the date is set in the past 'log_lifetime' => "-90", // numeric strings are also accepted and will follow same rules as integers -'log_lifetime' => "30 minutes ago", // valid date -'log_lifetime' => "last monday", // valid date -'log_lifetime' => "monday", // future date will be ignored -'log_lifetime' => "next month", // future date will be ignored ``` > 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/src/Writer/Stream.php b/src/Writer/Stream.php index e6abbfb..abd7723 100644 --- a/src/Writer/Stream.php +++ b/src/Writer/Stream.php @@ -15,6 +15,7 @@ use function chmod; use function dirname; +use function error_log; use function fclose; use function file_exists; use function filemtime; @@ -23,6 +24,8 @@ 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; @@ -39,6 +42,7 @@ use function touch; use function unlink; +use const PATHINFO_DIRNAME; use const PHP_EOL; class Stream extends AbstractWriter @@ -65,10 +69,11 @@ public function __construct( mixed $streamOrUrl, ?string $mode = null, ?string $logSeparator = null, - ?int $filePermissions = null, - ?string $logLifetime = null, - ?string $streamFormat = null, + ?int $filePermissions = null ) { + $logLifetime = null; + $streamFormat = null; + if ($streamOrUrl instanceof Traversable) { $streamOrUrl = iterator_to_array($streamOrUrl); } @@ -133,10 +138,8 @@ public function __construct( $this->setLogSeparator($logSeparator); } - if (null !== $logLifetime) { - if (is_numeric($logLifetime)) { - $logLifetime = ($logLifetime > 0 ? '-' . $logLifetime : $logLifetime) . ' days'; - } + if (is_numeric($logLifetime)) { + $logLifetime = ($logLifetime > 0 ? '-' . $logLifetime : $logLifetime) . ' days'; try { $logLifetime = (new DateTimeImmutable($logLifetime))->getTimestamp(); @@ -208,21 +211,34 @@ protected function removeOldLogs(): void } $streamData = stream_get_meta_data($this->stream); - $path = $streamData['uri']; - $uriParts = pathinfo($path); + $path = $streamData['uri']; + $directory = pathinfo($path, PATHINFO_DIRNAME); + + if (! is_dir($directory)) { + error_log("{$directory} is not a directory"); + return; + } - $files = scandir($uriParts['dirname']); - $matches = preg_grep('/^' . $this->streamFormat . '$/', $files); + $files = scandir($directory) ?: []; + $matches = preg_grep('/^' . $this->streamFormat . '$/', $files) ?: []; foreach ($matches as $match) { - $match = sprintf('%s/%s', $uriParts['dirname'], $match); + $match = sprintf('%s/%s', $directory, $match); + + if (is_link($match)) { + continue; + } + $fileTimestamp = filemtime($match); if ( $fileTimestamp !== false && $fileTimestamp < $this->logLifetime + && is_writable($directory) ) { - unlink($match); + if (! @unlink($match)) { + error_log("{$match} could not be deleted"); + } } } } From ee5fdf9478c8e5afae3dbcfdf1dd662a094a8fd2 Mon Sep 17 00:00:00 2001 From: Jurj-Bogdan Date: Tue, 14 Oct 2025 13:06:24 +0300 Subject: [PATCH 3/3] documentation tweaks Signed-off-by: Jurj-Bogdan --- docs/book/v5/configuring-writer.md | 10 ---------- log.global.php.dist | 4 +++- 2 files changed, 3 insertions(+), 11 deletions(-) diff --git a/docs/book/v5/configuring-writer.md b/docs/book/v5/configuring-writer.md index 68189bc..95915aa 100644 --- a/docs/book/v5/configuring-writer.md +++ b/docs/book/v5/configuring-writer.md @@ -53,14 +53,4 @@ 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. -> Future dates are ignored. - -Examples of values: - -```php -'log_lifetime' => null, // feature disabled -'log_lifetime' => 90, // will be converted to `-90 days` so the date is set in the past -'log_lifetime' => "-90", // numeric strings are also accepted and will follow same rules as integers -``` - > 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 0c5c0e6..2f883d0 100644 --- a/log.global.php.dist +++ b/log.global.php.dist @@ -18,7 +18,9 @@ return [ 'name' => 'MyFilter', ], ], - 'log_lifetime' => null, // optional + // 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, ], ], ],