diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index 490803d..ad6b80b 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -165,7 +165,7 @@ public static LoggerConfiguration File( /// Logger sink configuration. /// A formatter, such as , to convert the log events into /// text for the file. If control of regular text formatting is required, use the other - /// overload of + /// overload of /// and specify the outputTemplate parameter instead. /// /// Path to the file. @@ -181,7 +181,7 @@ public static LoggerConfiguration File( /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// The interval at which logging will roll over to a new file. - /// If true, a new file will be created when the file size limit is reached. Filenames + /// If true, a new file will be created when the file size limit is reached. Filenames /// will have a number appended in the format _NNN, with the first filename given no number. /// The maximum number of log files that will be retained, /// including the current log file. For unlimited retention, pass null. The default is 31. @@ -227,12 +227,16 @@ public static LoggerConfiguration File( /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// The interval at which logging will roll over to a new file. - /// If true, a new file will be created when the file size limit is reached. Filenames + /// If true, a new file will be created when the file size limit is reached. Filenames /// will have a number appended in the format _NNN, with the first filename given no number. /// The maximum number of log files that will be retained, /// including the current log file. For unlimited retention, pass null. The default is 31. /// Character encoding used to write the text file. The default is UTF-8 without BOM. /// Optionally enables hooking into log file lifecycle events. + /// The maximum time after the end of an interval that a rolling log file will be retained. + /// Must be greater than or equal to . + /// Ignored if is . + /// The default is to retain files indefinitely. /// Configuration object allowing method chaining. public static LoggerConfiguration File( this LoggerSinkConfiguration sinkConfiguration, @@ -249,7 +253,8 @@ public static LoggerConfiguration File( bool rollOnFileSizeLimit = false, int? retainedFileCountLimit = DefaultRetainedFileCountLimit, Encoding encoding = null, - FileLifecycleHooks hooks = null) + FileLifecycleHooks hooks = null, + TimeSpan? retainedFileTimeLimit = null) { if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration)); if (path == null) throw new ArgumentNullException(nameof(path)); @@ -258,7 +263,7 @@ public static LoggerConfiguration File( var formatter = new MessageTemplateTextFormatter(outputTemplate, formatProvider); return File(sinkConfiguration, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes, levelSwitch, buffered, shared, flushToDiskInterval, - rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks); + rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks, retainedFileTimeLimit); } /// @@ -267,7 +272,7 @@ public static LoggerConfiguration File( /// Logger sink configuration. /// A formatter, such as , to convert the log events into /// text for the file. If control of regular text formatting is required, use the other - /// overload of + /// overload of /// and specify the outputTemplate parameter instead. /// /// Path to the file. @@ -289,6 +294,10 @@ public static LoggerConfiguration File( /// including the current log file. For unlimited retention, pass null. The default is 31. /// Character encoding used to write the text file. The default is UTF-8 without BOM. /// Optionally enables hooking into log file lifecycle events. + /// The maximum time after the end of an interval that a rolling log file will be retained. + /// Must be greater than or equal to . + /// Ignored if is . + /// The default is to retain files indefinitely. /// Configuration object allowing method chaining. public static LoggerConfiguration File( this LoggerSinkConfiguration sinkConfiguration, @@ -304,7 +313,8 @@ public static LoggerConfiguration File( bool rollOnFileSizeLimit = false, int? retainedFileCountLimit = DefaultRetainedFileCountLimit, Encoding encoding = null, - FileLifecycleHooks hooks = null) + FileLifecycleHooks hooks = null, + TimeSpan? retainedFileTimeLimit = null) { if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -312,7 +322,7 @@ public static LoggerConfiguration File( return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes, levelSwitch, buffered, false, shared, flushToDiskInterval, encoding, rollingInterval, rollOnFileSizeLimit, - retainedFileCountLimit, hooks); + retainedFileCountLimit, hooks, retainedFileTimeLimit); } /// @@ -432,7 +442,7 @@ public static LoggerConfiguration File( if (path == null) throw new ArgumentNullException(nameof(path)); return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, null, levelSwitch, false, true, - false, null, encoding, RollingInterval.Infinite, false, null, hooks); + false, null, encoding, RollingInterval.Infinite, false, null, hooks, null); } static LoggerConfiguration ConfigureFile( @@ -450,13 +460,15 @@ static LoggerConfiguration ConfigureFile( RollingInterval rollingInterval, bool rollOnFileSizeLimit, int? retainedFileCountLimit, - FileLifecycleHooks hooks) + FileLifecycleHooks hooks, + TimeSpan? retainedFileTimeLimit) { if (addSink == null) throw new ArgumentNullException(nameof(addSink)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); if (path == null) throw new ArgumentNullException(nameof(path)); if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 0) throw new ArgumentException("Negative value provided; file size limit must be non-negative.", nameof(fileSizeLimitBytes)); if (retainedFileCountLimit.HasValue && retainedFileCountLimit < 1) throw new ArgumentException("At least one file must be retained.", nameof(retainedFileCountLimit)); + if (retainedFileTimeLimit.HasValue && retainedFileTimeLimit < TimeSpan.Zero) throw new ArgumentException("Negative value provided; retained file time limit must be non-negative.", nameof(retainedFileTimeLimit)); if (shared && buffered) throw new ArgumentException("Buffered writes are not available when file sharing is enabled.", nameof(buffered)); if (shared && hooks != null) throw new ArgumentException("File lifecycle hooks are not currently supported for shared log files.", nameof(hooks)); @@ -464,7 +476,7 @@ static LoggerConfiguration ConfigureFile( if (rollOnFileSizeLimit || rollingInterval != RollingInterval.Infinite) { - sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks); + sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit); } else { diff --git a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs index 43e5fad..e6d29d5 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -20,6 +20,7 @@ using Serilog.Debugging; using Serilog.Events; using Serilog.Formatting; +using System.Collections.Generic; namespace Serilog.Sinks.File { @@ -29,6 +30,7 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable readonly ITextFormatter _textFormatter; readonly long? _fileSizeLimitBytes; readonly int? _retainedFileCountLimit; + readonly TimeSpan? _retainedFileTimeLimit; readonly Encoding _encoding; readonly bool _buffered; readonly bool _shared; @@ -50,16 +52,19 @@ public RollingFileSink(string path, bool shared, RollingInterval rollingInterval, bool rollOnFileSizeLimit, - FileLifecycleHooks hooks) + FileLifecycleHooks hooks, + TimeSpan? retainedFileTimeLimit) { if (path == null) throw new ArgumentNullException(nameof(path)); - if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 0) throw new ArgumentException("Negative value provided; file size limit must be non-negative."); - if (retainedFileCountLimit.HasValue && retainedFileCountLimit < 1) throw new ArgumentException("Zero or negative value provided; retained file count limit must be at least 1."); + if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 0) throw new ArgumentException("Negative value provided; file size limit must be non-negative"); + if (retainedFileCountLimit.HasValue && retainedFileCountLimit < 1) throw new ArgumentException("Zero or negative value provided; retained file count limit must be at least 1"); + if (retainedFileTimeLimit.HasValue && retainedFileTimeLimit < TimeSpan.Zero) throw new ArgumentException("Negative value provided; retained file time limit must be non-negative.", nameof(retainedFileTimeLimit)); _roller = new PathRoller(path, rollingInterval); _textFormatter = textFormatter; _fileSizeLimitBytes = fileSizeLimitBytes; _retainedFileCountLimit = retainedFileCountLimit; + _retainedFileTimeLimit = retainedFileTimeLimit; _encoding = encoding; _buffered = buffered; _shared = shared; @@ -166,14 +171,14 @@ void OpenFile(DateTime now, int? minSequence = null) throw; } - ApplyRetentionPolicy(path); + ApplyRetentionPolicy(path, now); return; } } - void ApplyRetentionPolicy(string currentFilePath) + void ApplyRetentionPolicy(string currentFilePath, DateTime now) { - if (_retainedFileCountLimit == null) return; + if (_retainedFileCountLimit == null && _retainedFileTimeLimit == null) return; var currentFileName = Path.GetFileName(currentFilePath); @@ -181,17 +186,17 @@ void ApplyRetentionPolicy(string currentFilePath) // because files are only opened on response to an event being processed. var potentialMatches = Directory.GetFiles(_roller.LogFileDirectory, _roller.DirectorySearchPattern) .Select(Path.GetFileName) - .Union(new [] { currentFileName }); + .Union(new[] { currentFileName }); var newestFirst = _roller .SelectMatches(potentialMatches) .OrderByDescending(m => m.DateTime) - .ThenByDescending(m => m.SequenceNumber) - .Select(m => m.Filename); + .ThenByDescending(m => m.SequenceNumber); var toRemove = newestFirst - .Where(n => StringComparer.OrdinalIgnoreCase.Compare(currentFileName, n) != 0) - .Skip(_retainedFileCountLimit.Value - 1) + .Where(n => StringComparer.OrdinalIgnoreCase.Compare(currentFileName, n.Filename) != 0) + .SkipWhile((f, i) => ShouldRetainFile(f, i, now)) + .Select(x => x.Filename) .ToList(); foreach (var obsolete in toRemove) @@ -209,6 +214,20 @@ void ApplyRetentionPolicy(string currentFilePath) } } + bool ShouldRetainFile(RollingLogFile file, int index, DateTime now) + { + if (_retainedFileCountLimit.HasValue && index >= _retainedFileCountLimit.Value) + return false; + + if (_retainedFileTimeLimit.HasValue && file.DateTime.HasValue && + file.DateTime.Value < now.Subtract(_retainedFileTimeLimit.Value)) + { + return false; + } + + return true; + } + public void Dispose() { lock (_syncRoot) diff --git a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs index 70408b3..5d0879f 100644 --- a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.IO.Compression; @@ -71,6 +71,63 @@ public void WhenRetentionCountIsSetOldFilesAreDeleted() }); } + [Fact] + public void WhenRetentionTimeIsSetOldFilesAreDeleted() + { + LogEvent e1 = Some.InformationEvent(DateTime.Today.AddDays(-5)), + e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)), + e3 = Some.InformationEvent(e2.Timestamp.AddDays(5)); + + TestRollingEventSequence( + (pf, wt) => wt.File(pf, retainedFileTimeLimit: TimeSpan.FromDays(1), rollingInterval: RollingInterval.Day), + new[] {e1, e2, e3}, + files => + { + Assert.Equal(3, files.Count); + Assert.True(!System.IO.File.Exists(files[0])); + Assert.True(!System.IO.File.Exists(files[1])); + Assert.True(System.IO.File.Exists(files[2])); + }); + } + + [Fact] + public void WhenRetentionCountAndTimeIsSetOldFilesAreDeletedByTime() + { + LogEvent e1 = Some.InformationEvent(DateTime.Today.AddDays(-5)), + e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)), + e3 = Some.InformationEvent(e2.Timestamp.AddDays(5)); + + TestRollingEventSequence( + (pf, wt) => wt.File(pf, retainedFileCountLimit: 2, retainedFileTimeLimit: TimeSpan.FromDays(1), rollingInterval: RollingInterval.Day), + new[] {e1, e2, e3}, + files => + { + Assert.Equal(3, files.Count); + Assert.True(!System.IO.File.Exists(files[0])); + Assert.True(!System.IO.File.Exists(files[1])); + Assert.True(System.IO.File.Exists(files[2])); + }); + } + + [Fact] + public void WhenRetentionCountAndTimeIsSetOldFilesAreDeletedByCount() + { + LogEvent e1 = Some.InformationEvent(DateTime.Today.AddDays(-5)), + e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)), + e3 = Some.InformationEvent(e2.Timestamp.AddDays(5)); + + TestRollingEventSequence( + (pf, wt) => wt.File(pf, retainedFileCountLimit: 2, retainedFileTimeLimit: TimeSpan.FromDays(10), rollingInterval: RollingInterval.Day), + new[] {e1, e2, e3}, + files => + { + Assert.Equal(3, files.Count); + Assert.True(!System.IO.File.Exists(files[0])); + Assert.True(System.IO.File.Exists(files[1])); + Assert.True(System.IO.File.Exists(files[2])); + }); + } + [Fact] public void WhenRetentionCountAndArchivingHookIsSetOldFilesAreCopiedAndOriginalDeleted() { @@ -88,7 +145,6 @@ public void WhenRetentionCountAndArchivingHookIsSetOldFilesAreCopiedAndOriginalD Assert.True(!System.IO.File.Exists(files[0])); Assert.True(System.IO.File.Exists(files[1])); Assert.True(System.IO.File.Exists(files[2])); - Assert.True(System.IO.File.Exists(ArchiveOldLogsHook.AddTopDirectory(files[0], archiveDirectory))); }); }