Skip to content

Add ability for static base file and customizable file roll patterns. #345

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

Open
wants to merge 2 commits into
base: dev
Choose a base branch
from
Open
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
42 changes: 31 additions & 11 deletions src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,8 @@ public static LoggerConfiguration File(
Encoding encoding)
{
return File(sinkConfiguration, path, restrictedToMinimumLevel, outputTemplate, formatProvider, fileSizeLimitBytes, levelSwitch, buffered,
shared, flushToDiskInterval, rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, null);
shared, flushToDiskInterval, rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, null,
null);
}

/// <summary>
Expand All @@ -164,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, TimeSpan?)"/>
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?, bool, Func{DateTime?,string}?, string?)"/>
/// and specify the outputTemplate parameter instead.
/// </param>
/// <param name="path">Path to the file.</param>
Expand Down Expand Up @@ -236,6 +237,11 @@ public static LoggerConfiguration File(
/// 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>
/// <param name="keepPathStaticOnRoll">Use the initial file path as a static file.</param>
/// <param name="customFormatFunc">A custom function that returns a custom string for rolling over files.
/// Accepts a DateTime for using custom DateTime formats.
/// This must return a string that can be matched by <paramref name="customRollPattern"/>.</param>
/// <param name="customRollPattern">A custom pattern for rolling over files. This must compile into a <see cref="System.Text.RegularExpressions.Regex"/> </param>
/// <returns>Configuration object allowing method chaining.</returns>
/// <exception cref="ArgumentNullException">When <paramref name="sinkConfiguration"/> is <code>null</code></exception>
/// <exception cref="ArgumentNullException">When <paramref name="path"/> is <code>null</code></exception>
Expand All @@ -262,7 +268,10 @@ public static LoggerConfiguration File(
int? retainedFileCountLimit = DefaultRetainedFileCountLimit,
Encoding? encoding = null,
FileLifecycleHooks? hooks = null,
TimeSpan? retainedFileTimeLimit = null)
TimeSpan? retainedFileTimeLimit = null,
bool keepPathStaticOnRoll = false,
Func<DateTime?,string>? customFormatFunc = null,
string? customRollPattern = null)
{
if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration));
if (path == null) throw new ArgumentNullException(nameof(path));
Expand All @@ -271,7 +280,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, retainedFileTimeLimit);
rollingInterval, rollOnFileSizeLimit, retainedFileCountLimit, encoding, hooks, retainedFileTimeLimit, keepPathStaticOnRoll, customFormatFunc, customRollPattern);
}

/// <summary>
Expand All @@ -280,7 +289,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, TimeSpan?)"/>
/// overload of <see cref="File(LoggerSinkConfiguration, string, LogEventLevel, string, IFormatProvider, long?, LoggingLevelSwitch, bool, bool, TimeSpan?, RollingInterval, bool, int?, Encoding, FileLifecycleHooks, TimeSpan?, bool, Func{DateTime?, string}?, string?)"/>
/// and specify the outputTemplate parameter instead.
/// </param>
/// <param name="path">Path to the file.</param>
Expand All @@ -306,6 +315,9 @@ public static LoggerConfiguration File(
/// 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>
/// <param name="keepPathStaticOnRoll">Use the initial file path as a static file.</param>
/// <param name="customFormatFunc">A custom function that returns a custom string for rolling over files. This must return a string that can be matched by <paramref name="customRollPattern"/>.</param>
/// <param name="customRollPattern">A custom pattern for rolling over files. This must compile into a <see cref="System.Text.RegularExpressions.Regex"/> </param>
/// <returns>Configuration object allowing method chaining.</returns>
/// <exception cref="ArgumentNullException">When <paramref name="sinkConfiguration"/> is <code>null</code></exception>
/// <exception cref="ArgumentNullException">When <paramref name="formatter"/> is <code>null</code></exception>
Expand All @@ -331,15 +343,18 @@ public static LoggerConfiguration File(
int? retainedFileCountLimit = DefaultRetainedFileCountLimit,
Encoding? encoding = null,
FileLifecycleHooks? hooks = null,
TimeSpan? retainedFileTimeLimit = null)
TimeSpan? retainedFileTimeLimit = null,
bool keepPathStaticOnRoll = false,
Func<DateTime?,string>? customFormatFunc = null,
string? customRollPattern = 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, retainedFileTimeLimit);
retainedFileCountLimit, hooks, retainedFileTimeLimit, keepPathStaticOnRoll, customFormatFunc, customRollPattern);
}

/// <summary>
Expand Down Expand Up @@ -487,14 +502,15 @@ public static LoggerConfiguration File(
LogEventLevel restrictedToMinimumLevel = LevelAlias.Minimum,
LoggingLevelSwitch? levelSwitch = null,
Encoding? encoding = null,
FileLifecycleHooks? hooks = null)
FileLifecycleHooks? hooks = 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, null, levelSwitch, false, true,
false, null, encoding, RollingInterval.Infinite, false, null, hooks, null);
false, null, encoding, RollingInterval.Infinite, false, null, hooks, null, false, null, null);
}

static LoggerConfiguration ConfigureFile(
Expand All @@ -513,7 +529,10 @@ static LoggerConfiguration ConfigureFile(
bool rollOnFileSizeLimit,
int? retainedFileCountLimit,
FileLifecycleHooks? hooks,
TimeSpan? retainedFileTimeLimit)
TimeSpan? retainedFileTimeLimit,
bool keepPathStaticOnRoll,
Func<DateTime?,string>? customFormatFunc,
string? customRollPattern)
{
if (addSink == null) throw new ArgumentNullException(nameof(addSink));
if (formatter == null) throw new ArgumentNullException(nameof(formatter));
Expand All @@ -523,14 +542,15 @@ static LoggerConfiguration ConfigureFile(
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));
if(keepPathStaticOnRoll && (fileSizeLimitBytes == null && rollingInterval == RollingInterval.Infinite )) throw new ArgumentException("keepPathStaticOnRoll is only supported when either fileSizeLimitBytes or rollingInterval are enabled");

ILogEventSink sink;

try
{
if (rollOnFileSizeLimit || rollingInterval != RollingInterval.Infinite)
{
sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit);
sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit, keepPathStaticOnRoll, customFormatFunc, customRollPattern);
}
else
{
Expand Down
19 changes: 16 additions & 3 deletions src/Serilog.Sinks.File/Sinks/File/FileSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene
/// <param name="encoding">Character encoding used to write the text file. The default is UTF-8 without BOM.</param>
/// <param name="buffered">Indicates if flushing to the output file can be buffered or not. The default
/// is false.</param>
/// <param name="keepPathStatic">Indicates that the path is static/persistent</param>
/// <returns>Configuration object allowing method chaining.</returns>
/// <remarks>This constructor preserves compatibility with early versions of the public API. New code should not depend on this type.</remarks>
/// <exception cref="ArgumentNullException">When <paramref name="textFormatter"/> is <code>null</code></exception>
Expand All @@ -56,7 +57,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene
/// <exception cref="UnauthorizedAccessException">The caller does not have the required permission to access the <paramref name="path"/></exception>
/// <exception cref="ArgumentException">Invalid <paramref name="path"/></exception>
[Obsolete("This type and constructor will be removed from the public API in a future version; use `WriteTo.File()` instead.")]
public FileSink(string path, ITextFormatter textFormatter, long? fileSizeLimitBytes, Encoding? encoding = null, bool buffered = false)
public FileSink(string path, ITextFormatter textFormatter, long? fileSizeLimitBytes, Encoding? encoding = null, bool buffered = false, bool keepPathStatic = false)
: this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null)
{
}
Expand All @@ -68,7 +69,9 @@ internal FileSink(
long? fileSizeLimitBytes,
Encoding? encoding,
bool buffered,
FileLifecycleHooks? hooks)
FileLifecycleHooks? hooks,
bool keepPathStatic = false
)
{
if (path == null) throw new ArgumentNullException(nameof(path));
if (fileSizeLimitBytes is < 1) throw new ArgumentException("Invalid value provided; file size limit must be at least 1 byte, or null.");
Expand All @@ -82,7 +85,16 @@ internal FileSink(
Directory.CreateDirectory(directory);
}

Stream outputStream = _underlyingStream = System.IO.File.Open(path, FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read);
FileMode fileOpenMode;
if (System.IO.File.Exists(path) && keepPathStatic)
{
fileOpenMode = FileMode.Truncate;
}
else
{
fileOpenMode = FileMode.OpenOrCreate;
}
Stream outputStream = _underlyingStream = System.IO.File.Open(path, fileOpenMode, FileAccess.Write, FileShare.Read);
outputStream.Seek(0, SeekOrigin.End);

if (_fileSizeLimitBytes != null)
Expand All @@ -99,6 +111,7 @@ internal FileSink(
{
outputStream = hooks.OnFileOpened(path, outputStream, encoding) ??
throw new InvalidOperationException($"The file lifecycle hook `{nameof(FileLifecycleHooks.OnFileOpened)}(...)` returned `null`.");

}
catch
{
Expand Down
125 changes: 114 additions & 11 deletions src/Serilog.Sinks.File/Sinks/File/PathRoller.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,29 @@ sealed class PathRoller
readonly string _filenamePrefix;
readonly string _filenameSuffix;
readonly Regex _filenameMatcher;
readonly bool _keepPathStatic;
readonly string? _customRollPattern;
private Func<DateTime?, string>? _customFormatFunc;

readonly RollingInterval _interval;
readonly string _periodFormat;

public PathRoller(string path, RollingInterval interval)
public PathRoller(string path, RollingInterval interval, bool keepPathStatic = false, Func<DateTime?,string>? customFormatFunc = null,
string? customRollPattern = null)
{
if (path == null) throw new ArgumentNullException(nameof(path));
_interval = interval;
_periodFormat = interval.GetFormat();
_keepPathStatic = keepPathStatic;
_customRollPattern = customRollPattern;
_customFormatFunc = customFormatFunc;

if (_customRollPattern != null && _customFormatFunc != null)
{
ValidateCustomRollPattern(_customRollPattern);
ValidateCustomFormatFuncMatchesPattern(customFormatFunc, _customRollPattern);
}



var pathDirectory = Path.GetDirectoryName(path);
if (string.IsNullOrEmpty(pathDirectory))
Expand All @@ -43,30 +57,107 @@ public PathRoller(string path, RollingInterval interval)
_directory = Path.GetFullPath(pathDirectory);
_filenamePrefix = Path.GetFileNameWithoutExtension(path);
_filenameSuffix = Path.GetExtension(path);
_filenameMatcher = new Regex(
if (_customRollPattern == null)
{
_periodFormat = interval.GetFormat();
_filenameMatcher = new Regex(
"^" +
Regex.Escape(_filenamePrefix) +
"(?<" + PeriodMatchGroup + ">\\d{" + _periodFormat.Length + "})" +
"(?<" + SequenceNumberMatchGroup + ">_[0-9]{3,}){0,1}" +
Regex.Escape(_filenameSuffix) +
"$",
RegexOptions.Compiled);
}
else
{
_periodFormat = _customRollPattern;
_filenameMatcher = new Regex(
"^" +
Regex.Escape(_filenamePrefix) +
"(?<" + PeriodMatchGroup + ">\\d{" + _periodFormat.Length + "})" +
"(?<" + SequenceNumberMatchGroup + ">_[0-9]{3,}){0,1}" +
Regex.Escape(_filenameSuffix) +
"$",
Regex.Escape(_filenamePrefix) +
"(?<" + PeriodMatchGroup + ">" + _customRollPattern + ")" +
"(?<" + SequenceNumberMatchGroup + ">_[0-9]{3,}){0,1}" +
Regex.Escape(_filenameSuffix) +
"$",
RegexOptions.Compiled);
}

DirectorySearchPattern = $"{_filenamePrefix}*{_filenameSuffix}";
}

private void ValidateCustomFormatFuncMatchesPattern(Func<DateTime?, string>? customFormatFunc, string customRollPattern)
{
var temp = customFormatFunc?.Invoke(DateTime.Now);
if (temp == null)
{
throw new ArgumentException("Custom format function did not return a value.", nameof(customFormatFunc));
}

if (!Regex.IsMatch(temp, customRollPattern))
{
throw new ArgumentException($"Custom format function does not match the custom roll pattern of {customRollPattern}.", nameof(customFormatFunc));
}

}

public string LogFileDirectory => _directory;

public string DirectorySearchPattern { get; }

public void GetLogFilePath(DateTime date, int? sequenceNumber, out string path)
public void GetLogFilePath(DateTime date, int? sequenceNumber, out string path, out string? copyPath)
{
var currentCheckpoint = GetCurrentCheckpoint(date);

var tok = currentCheckpoint?.ToString(_periodFormat, CultureInfo.InvariantCulture) ?? "";
var tok = GetToken(sequenceNumber, currentCheckpoint);

if (_keepPathStatic)
{
path = Path.Combine(_directory, _filenamePrefix + _filenameSuffix);

copyPath = Path.Combine(_directory, _filenamePrefix + tok + _filenameSuffix);
return;
}

copyPath = null;
GetLogFilePath(date, sequenceNumber, out path);
}

private string GetToken(int? sequenceNumber, DateTime? currentCheckpoint)
{
var tok = string.Empty;

if (sequenceNumber != null)
if (_customFormatFunc == null)
{
tok = currentCheckpoint?.ToString(_periodFormat, CultureInfo.InvariantCulture) ?? "";
}
else if( _customFormatFunc != null && sequenceNumber == null && currentCheckpoint != null)
{
tok = _customFormatFunc.Invoke(currentCheckpoint);
}
else if( _customFormatFunc != null && sequenceNumber != null && currentCheckpoint == null)
{
tok = _customFormatFunc.Invoke(DateTime.Now);
}
else if (_customFormatFunc != null && sequenceNumber == null && currentCheckpoint == null)
{
return string.Empty;
}

if (sequenceNumber == null) return tok;
var path = Path.Combine(_directory, _filenamePrefix + tok + _filenameSuffix);
if (System.IO.File.Exists(path))
{
tok += "_" + sequenceNumber.Value.ToString("000", CultureInfo.InvariantCulture);
}

return tok;
}

public void GetLogFilePath(DateTime date, int? sequenceNumber, out string path)
{
var currentCheckpoint = GetCurrentCheckpoint(date);

var tok = GetToken(sequenceNumber, currentCheckpoint);

path = Path.Combine(_directory, _filenamePrefix + tok + _filenameSuffix);
}
Expand Down Expand Up @@ -110,4 +201,16 @@ public IEnumerable<RollingLogFile> SelectMatches(IEnumerable<string> filenames)
public DateTime? GetCurrentCheckpoint(DateTime instant) => _interval.GetCurrentCheckpoint(instant);

public DateTime? GetNextCheckpoint(DateTime instant) => _interval.GetNextCheckpoint(instant);

private void ValidateCustomRollPattern(string pattern)
{
try
{
_ = new Regex(pattern);
}
catch (ArgumentException)
{
throw new ArgumentException("The custom roll pattern is not a valid regex pattern.", nameof(pattern));
}
}
}
Loading