Skip to content

Commit 1596f80

Browse files
committed
File retention policy by date - Add configuration to discard files older than X time
Fixed constructors for compatibility; Unified where clause to retain files Adjust error messages New test cases
1 parent ab568af commit 1596f80

File tree

3 files changed

+99
-19
lines changed

3 files changed

+99
-19
lines changed

src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs

Lines changed: 25 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ public static LoggerConfiguration File(
138138
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
139139
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
140140
/// <returns>Configuration object allowing method chaining.</returns>
141+
/// <remarks>The file will be written using the UTF-8 character set.</remarks>
141142
[Obsolete("New code should not be compiled against this obsolete overload"), EditorBrowsable(EditorBrowsableState.Never)]
142143
public static LoggerConfiguration File(
143144
this LoggerSinkConfiguration sinkConfiguration,
@@ -165,7 +166,7 @@ public static LoggerConfiguration File(
165166
/// <param name="sinkConfiguration">Logger sink configuration.</param>
166167
/// <param name="formatter">A formatter, such as <see cref="JsonFormatter"/>, to convert the log events into
167168
/// text for the file. If control of regular text formatting is required, use the other
168-
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks)"/>
169+
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?)"/>
169170
/// and specify the outputTemplate parameter instead.
170171
/// </param>
171172
/// <param name="path">Path to the file.</param>
@@ -181,7 +182,7 @@ public static LoggerConfiguration File(
181182
/// <param name="shared">Allow the log file to be shared by multiple processes. The default is false.</param>
182183
/// <param name="flushToDiskInterval">If provided, a full disk flush will be performed periodically at the specified interval.</param>
183184
/// <param name="rollingInterval">The interval at which logging will roll over to a new file.</param>
184-
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
185+
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
185186
/// will have a number appended in the format <code>_NNN</code>, with the first filename given no number.</param>
186187
/// <param name="retainedFileCountLimit">The maximum number of log files that will be retained,
187188
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
@@ -227,13 +228,18 @@ public static LoggerConfiguration File(
227228
/// <param name="shared">Allow the log file to be shared by multiple processes. The default is false.</param>
228229
/// <param name="flushToDiskInterval">If provided, a full disk flush will be performed periodically at the specified interval.</param>
229230
/// <param name="rollingInterval">The interval at which logging will roll over to a new file.</param>
230-
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
231+
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
231232
/// will have a number appended in the format <code>_NNN</code>, with the first filename given no number.</param>
232233
/// <param name="retainedFileCountLimit">The maximum number of log files that will be retained,
233234
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
234235
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
235236
/// <param name="hooks">Optionally enables hooking into log file lifecycle events.</param>
237+
/// <param name="retainedFileTimeLimit">The maximum time after the end of an interval that a rolling log file will be retained.
238+
/// Must be greater than or equal to <see cref="TimeSpan.Zero"/>.
239+
/// Ignored if <paramref see="rollingInterval"/> is <see cref="RollingInterval.Infinite"/>.
240+
/// The default is to retain files indefinitely.</param>
236241
/// <returns>Configuration object allowing method chaining.</returns>
242+
/// <remarks>The file will be written using the UTF-8 character set.</remarks>
237243
public static LoggerConfiguration File(
238244
this LoggerSinkConfiguration sinkConfiguration,
239245
string path,
@@ -249,7 +255,8 @@ public static LoggerConfiguration File(
249255
bool rollOnFileSizeLimit = false,
250256
int? retainedFileCountLimit = DefaultRetainedFileCountLimit,
251257
Encoding encoding = null,
252-
FileLifecycleHooks hooks = null)
258+
FileLifecycleHooks hooks = null,
259+
TimeSpan? retainedFileTimeLimit = null)
253260
{
254261
if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration));
255262
if (path == null) throw new ArgumentNullException(nameof(path));
@@ -258,7 +265,7 @@ public static LoggerConfiguration File(
258265
var formatter = new MessageTemplateTextFormatter(outputTemplate, formatProvider);
259266
return File(sinkConfiguration, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes,
260267
levelSwitch, buffered, shared, flushToDiskInterval,
261-
rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks);
268+
rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks, retainedFileTimeLimit);
262269
}
263270

264271
/// <summary>
@@ -267,7 +274,7 @@ public static LoggerConfiguration File(
267274
/// <param name="sinkConfiguration">Logger sink configuration.</param>
268275
/// <param name="formatter">A formatter, such as <see cref="JsonFormatter"/>, to convert the log events into
269276
/// text for the file. If control of regular text formatting is required, use the other
270-
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks)"/>
277+
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?)"/>
271278
/// and specify the outputTemplate parameter instead.
272279
/// </param>
273280
/// <param name="path">Path to the file.</param>
@@ -289,6 +296,10 @@ public static LoggerConfiguration File(
289296
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
290297
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
291298
/// <param name="hooks">Optionally enables hooking into log file lifecycle events.</param>
299+
/// <param name="retainedFileTimeLimit">The maximum time after the end of an interval that a rolling log file will be retained.
300+
/// Must be greater than or equal to <see cref="TimeSpan.Zero"/>.
301+
/// Ignored if <paramref see="rollingInterval"/> is <see cref="RollingInterval.Infinite"/>.
302+
/// The default is to retain files indefinitely.</param>
292303
/// <returns>Configuration object allowing method chaining.</returns>
293304
public static LoggerConfiguration File(
294305
this LoggerSinkConfiguration sinkConfiguration,
@@ -304,15 +315,16 @@ public static LoggerConfiguration File(
304315
bool rollOnFileSizeLimit = false,
305316
int? retainedFileCountLimit = DefaultRetainedFileCountLimit,
306317
Encoding encoding = null,
307-
FileLifecycleHooks hooks = null)
318+
FileLifecycleHooks hooks = null,
319+
TimeSpan? retainedFileTimeLimit = null)
308320
{
309321
if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration));
310322
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
311323
if (path == null) throw new ArgumentNullException(nameof(path));
312324

313325
return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes, levelSwitch,
314326
buffered, false, shared, flushToDiskInterval, encoding, rollingInterval, rollOnFileSizeLimit,
315-
retainedFileCountLimit, hooks);
327+
retainedFileCountLimit, hooks, retainedFileTimeLimit);
316328
}
317329

318330
/// <summary>
@@ -432,7 +444,7 @@ public static LoggerConfiguration File(
432444
if (path == null) throw new ArgumentNullException(nameof(path));
433445

434446
return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, null, levelSwitch, false, true,
435-
false, null, encoding, RollingInterval.Infinite, false, null, hooks);
447+
false, null, encoding, RollingInterval.Infinite, false, null, hooks, null);
436448
}
437449

438450
static LoggerConfiguration ConfigureFile(
@@ -450,21 +462,23 @@ static LoggerConfiguration ConfigureFile(
450462
RollingInterval rollingInterval,
451463
bool rollOnFileSizeLimit,
452464
int? retainedFileCountLimit,
453-
FileLifecycleHooks hooks)
465+
FileLifecycleHooks hooks,
466+
TimeSpan? retainedFileTimeLimit)
454467
{
455468
if (addSink == null) throw new ArgumentNullException(nameof(addSink));
456469
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
457470
if (path == null) throw new ArgumentNullException(nameof(path));
458471
if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 0) throw new ArgumentException("Negative value provided; file size limit must be non-negative.", nameof(fileSizeLimitBytes));
459472
if (retainedFileCountLimit.HasValue && retainedFileCountLimit < 1) throw new ArgumentException("At least one file must be retained.", nameof(retainedFileCountLimit));
473+
if (retainedFileTimeLimit.HasValue && retainedFileTimeLimit < TimeSpan.Zero) throw new ArgumentException("Negative value provided; retained file time limit must be non-negative.", nameof(retainedFileTimeLimit));
460474
if (shared && buffered) throw new ArgumentException("Buffered writes are not available when file sharing is enabled.", nameof(buffered));
461475
if (shared && hooks != null) throw new ArgumentException("File lifecycle hooks are not currently supported for shared log files.", nameof(hooks));
462476

463477
ILogEventSink sink;
464478

465479
if (rollOnFileSizeLimit || rollingInterval != RollingInterval.Infinite)
466480
{
467-
sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks);
481+
sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit);
468482
}
469483
else
470484
{

src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
using Serilog.Debugging;
2121
using Serilog.Events;
2222
using Serilog.Formatting;
23+
using System.Collections.Generic;
2324

2425
namespace Serilog.Sinks.File
2526
{
@@ -29,6 +30,7 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable
2930
readonly ITextFormatter _textFormatter;
3031
readonly long? _fileSizeLimitBytes;
3132
readonly int? _retainedFileCountLimit;
33+
readonly TimeSpan? _retainedFileTimeLimit;
3234
readonly Encoding _encoding;
3335
readonly bool _buffered;
3436
readonly bool _shared;
@@ -50,16 +52,19 @@ public RollingFileSink(string path,
5052
bool shared,
5153
RollingInterval rollingInterval,
5254
bool rollOnFileSizeLimit,
53-
FileLifecycleHooks hooks)
55+
FileLifecycleHooks hooks,
56+
TimeSpan? retainedFileTimeLimit)
5457
{
5558
if (path == null) throw new ArgumentNullException(nameof(path));
56-
if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 0) throw new ArgumentException("Negative value provided; file size limit must be non-negative.");
57-
if (retainedFileCountLimit.HasValue && retainedFileCountLimit < 1) throw new ArgumentException("Zero or negative value provided; retained file count limit must be at least 1.");
59+
if (fileSizeLimitBytes.HasValue && fileSizeLimitBytes < 0) throw new ArgumentException("Negative value provided; file size limit must be non-negative");
60+
if (retainedFileCountLimit.HasValue && retainedFileCountLimit < 1) throw new ArgumentException("Zero or negative value provided; retained file count limit must be at least 1");
61+
if (retainedFileTimeLimit.HasValue && retainedFileTimeLimit < TimeSpan.Zero) throw new ArgumentException("Negative value provided; retained file time limit must be non-negative.", nameof(retainedFileTimeLimit));
5862

5963
_roller = new PathRoller(path, rollingInterval);
6064
_textFormatter = textFormatter;
6165
_fileSizeLimitBytes = fileSizeLimitBytes;
6266
_retainedFileCountLimit = retainedFileCountLimit;
67+
_retainedFileTimeLimit = retainedFileTimeLimit;
6368
_encoding = encoding;
6469
_buffered = buffered;
6570
_shared = shared;
@@ -173,25 +178,29 @@ void OpenFile(DateTime now, int? minSequence = null)
173178

174179
void ApplyRetentionPolicy(string currentFilePath)
175180
{
176-
if (_retainedFileCountLimit == null) return;
181+
if (_retainedFileCountLimit == null && _retainedFileTimeLimit == null) return;
177182

178183
var currentFileName = Path.GetFileName(currentFilePath);
179184

180185
// We consider the current file to exist, even if nothing's been written yet,
181186
// because files are only opened on response to an event being processed.
182187
var potentialMatches = Directory.GetFiles(_roller.LogFileDirectory, _roller.DirectorySearchPattern)
183188
.Select(Path.GetFileName)
184-
.Union(new [] { currentFileName });
189+
.Union(new[] { currentFileName });
185190

186191
var newestFirst = _roller
187192
.SelectMatches(potentialMatches)
188193
.OrderByDescending(m => m.DateTime)
189194
.ThenByDescending(m => m.SequenceNumber)
190-
.Select(m => m.Filename);
195+
.Select(m => new { m.Filename, m.DateTime });
191196

192197
var toRemove = newestFirst
193-
.Where(n => StringComparer.OrdinalIgnoreCase.Compare(currentFileName, n) != 0)
194-
.Skip(_retainedFileCountLimit.Value - 1)
198+
.Where(n => StringComparer.OrdinalIgnoreCase.Compare(currentFileName, n.Filename) != 0)
199+
.SkipWhile((x, i) => (i < (_retainedFileCountLimit - 1 ?? 0)) &&
200+
(!_retainedFileTimeLimit.HasValue ||
201+
x.DateTime.HasValue &&
202+
DateTime.Now.Subtract(_retainedFileTimeLimit.Value).CompareTo(x.DateTime.Value) <= 0))
203+
.Select(x => x.Filename)
195204
.ToList();
196205

197206
foreach (var obsolete in toRemove)

test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,63 @@ public void WhenRetentionCountIsSetOldFilesAreDeleted()
7171
});
7272
}
7373

74+
[Fact]
75+
public void WhenRetentionTimeIsSetOldFilesAreDeleted()
76+
{
77+
LogEvent e1 = Some.InformationEvent(DateTime.Today.AddDays(-5)),
78+
e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)),
79+
e3 = Some.InformationEvent(e2.Timestamp.AddDays(5));
80+
81+
TestRollingEventSequence(
82+
(pf, wt) => wt.File(pf, retainedFileTimeLimit: TimeSpan.FromDays(1), rollingInterval: RollingInterval.Day),
83+
new[] {e1, e2, e3},
84+
files =>
85+
{
86+
Assert.Equal(3, files.Count);
87+
Assert.True(!System.IO.File.Exists(files[0]));
88+
Assert.True(!System.IO.File.Exists(files[1]));
89+
Assert.True(System.IO.File.Exists(files[2]));
90+
});
91+
}
92+
93+
[Fact]
94+
public void WhenRetentionCountAndTimeIsSetOldFilesAreDeletedByTime()
95+
{
96+
LogEvent e1 = Some.InformationEvent(DateTime.Today.AddDays(-5)),
97+
e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)),
98+
e3 = Some.InformationEvent(e2.Timestamp.AddDays(5));
99+
100+
TestRollingEventSequence(
101+
(pf, wt) => wt.File(pf, retainedFileCountLimit: 2, retainedFileTimeLimit: TimeSpan.FromDays(1), rollingInterval: RollingInterval.Day),
102+
new[] {e1, e2, e3},
103+
files =>
104+
{
105+
Assert.Equal(3, files.Count);
106+
Assert.True(!System.IO.File.Exists(files[0]));
107+
Assert.True(!System.IO.File.Exists(files[1]));
108+
Assert.True(System.IO.File.Exists(files[2]));
109+
});
110+
}
111+
112+
[Fact]
113+
public void WhenRetentionCountAndTimeIsSetOldFilesAreDeletedByCount()
114+
{
115+
LogEvent e1 = Some.InformationEvent(DateTime.Today.AddDays(-5)),
116+
e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)),
117+
e3 = Some.InformationEvent(e2.Timestamp.AddDays(5));
118+
119+
TestRollingEventSequence(
120+
(pf, wt) => wt.File(pf, retainedFileCountLimit: 2, retainedFileTimeLimit: TimeSpan.FromDays(10), rollingInterval: RollingInterval.Day),
121+
new[] {e1, e2, e3},
122+
files =>
123+
{
124+
Assert.Equal(3, files.Count);
125+
Assert.True(!System.IO.File.Exists(files[0]));
126+
Assert.True(System.IO.File.Exists(files[1]));
127+
Assert.True(System.IO.File.Exists(files[2]));
128+
});
129+
}
130+
74131
[Fact]
75132
public void WhenSizeLimitIsBreachedNewFilesCreated()
76133
{

0 commit comments

Comments
 (0)