Skip to content

File retention policy by date/time #90

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Feb 4, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 23 additions & 11 deletions src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ public static LoggerConfiguration File(
/// <param name="sinkConfiguration">Logger sink configuration.</param>
/// <param name="formatter">A formatter, such as <see cref="JsonFormatter"/>, to convert the log events into
/// text for the file. If control of regular text formatting is required, use the other
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks)"/>
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?)"/>
/// and specify the outputTemplate parameter instead.
/// </param>
/// <param name="path">Path to the file.</param>
Expand All @@ -181,7 +181,7 @@ public static LoggerConfiguration File(
/// <param name="shared">Allow the log file to be shared by multiple processes. The default is false.</param>
/// <param name="flushToDiskInterval">If provided, a full disk flush will be performed periodically at the specified interval.</param>
/// <param name="rollingInterval">The interval at which logging will roll over to a new file.</param>
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
/// will have a number appended in the format <code>_NNN</code>, with the first filename given no number.</param>
/// <param name="retainedFileCountLimit">The maximum number of log files that will be retained,
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
Expand Down Expand Up @@ -227,12 +227,16 @@ public static LoggerConfiguration File(
/// <param name="shared">Allow the log file to be shared by multiple processes. The default is false.</param>
/// <param name="flushToDiskInterval">If provided, a full disk flush will be performed periodically at the specified interval.</param>
/// <param name="rollingInterval">The interval at which logging will roll over to a new file.</param>
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
/// <param name="rollOnFileSizeLimit">If <code>true</code>, a new file will be created when the file size limit is reached. Filenames
/// will have a number appended in the format <code>_NNN</code>, with the first filename given no number.</param>
/// <param name="retainedFileCountLimit">The maximum number of log files that will be retained,
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
/// <param name="hooks">Optionally enables hooking into log file lifecycle events.</param>
/// <param name="retainedFileTimeLimit">The maximum time after the end of an interval that a rolling log file will be retained.
/// Must be greater than or equal to <see cref="TimeSpan.Zero"/>.
/// Ignored if <paramref see="rollingInterval"/> is <see cref="RollingInterval.Infinite"/>.
/// The default is to retain files indefinitely.</param>
/// <returns>Configuration object allowing method chaining.</returns>
public static LoggerConfiguration File(
this LoggerSinkConfiguration sinkConfiguration,
Expand All @@ -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));
Expand All @@ -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);
}

/// <summary>
Expand All @@ -267,7 +272,7 @@ public static LoggerConfiguration File(
/// <param name="sinkConfiguration">Logger sink configuration.</param>
/// <param name="formatter">A formatter, such as <see cref="JsonFormatter"/>, to convert the log events into
/// text for the file. If control of regular text formatting is required, use the other
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks)"/>
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?)"/>
/// and specify the outputTemplate parameter instead.
/// </param>
/// <param name="path">Path to the file.</param>
Expand All @@ -289,6 +294,10 @@ public static LoggerConfiguration File(
/// including the current log file. For unlimited retention, pass null. The default is 31.</param>
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
/// <param name="hooks">Optionally enables hooking into log file lifecycle events.</param>
/// <param name="retainedFileTimeLimit">The maximum time after the end of an interval that a rolling log file will be retained.
/// Must be greater than or equal to <see cref="TimeSpan.Zero"/>.
/// Ignored if <paramref see="rollingInterval"/> is <see cref="RollingInterval.Infinite"/>.
/// The default is to retain files indefinitely.</param>
/// <returns>Configuration object allowing method chaining.</returns>
public static LoggerConfiguration File(
this LoggerSinkConfiguration sinkConfiguration,
Expand All @@ -304,15 +313,16 @@ 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));
if (path == null) throw new ArgumentNullException(nameof(path));

return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes, levelSwitch,
buffered, false, shared, flushToDiskInterval, encoding, rollingInterval, rollOnFileSizeLimit,
retainedFileCountLimit, hooks);
retainedFileCountLimit, hooks, retainedFileTimeLimit);
}

/// <summary>
Expand Down Expand Up @@ -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(
Expand All @@ -450,21 +460,23 @@ 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));

ILogEventSink sink;

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
{
Expand Down
41 changes: 30 additions & 11 deletions src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
using Serilog.Debugging;
using Serilog.Events;
using Serilog.Formatting;
using System.Collections.Generic;

namespace Serilog.Sinks.File
{
Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -166,32 +171,32 @@ 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);

// We consider the current file to exist, even if nothing's been written yet,
// 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)
Expand All @@ -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)
Expand Down
60 changes: 58 additions & 2 deletions test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
Expand Down Expand Up @@ -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()
{
Expand All @@ -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)));
});
}
Expand Down