From ee47592c9e720ede47128bae5a3813aa5952a205 Mon Sep 17 00:00:00 2001 From: Justin Moore Date: Mon, 17 Mar 2025 12:47:41 -0700 Subject: [PATCH 1/2] Add ability for static base file and customizable file roll patterns. --- .../FileLoggerConfigurationExtensions.cs | 42 +++-- src/Serilog.Sinks.File/Sinks/File/FileSink.cs | 19 +- .../Sinks/File/PathRoller.cs | 125 +++++++++++-- .../Sinks/File/RollingFileSink.cs | 44 ++++- .../RollingFileSinkTests.cs | 170 +++++++++++++++++- 5 files changed, 366 insertions(+), 34 deletions(-) diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index e3e8bcf..2cb0508 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -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); } /// @@ -164,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. @@ -236,6 +237,11 @@ public static LoggerConfiguration File( /// Must be greater than or equal to . /// Ignored if is . /// The default is to retain files indefinitely. + /// Use the initial file path as a static file. + /// 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 . + /// A custom pattern for rolling over files. This must compile into a /// Configuration object allowing method chaining. /// When is null /// When is null @@ -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? customFormatFunc = null, + string? customRollPattern = null) { if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration)); if (path == null) throw new ArgumentNullException(nameof(path)); @@ -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); } /// @@ -280,7 +289,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. @@ -306,6 +315,9 @@ public static LoggerConfiguration File( /// Must be greater than or equal to . /// Ignored if is . /// The default is to retain files indefinitely. + /// Use the initial file path as a static file. + /// A custom function that returns a custom string for rolling over files. This must return a string that can be matched by . + /// A custom pattern for rolling over files. This must compile into a /// Configuration object allowing method chaining. /// When is null /// When is null @@ -331,7 +343,10 @@ public static LoggerConfiguration File( int? retainedFileCountLimit = DefaultRetainedFileCountLimit, Encoding? encoding = null, FileLifecycleHooks? hooks = null, - TimeSpan? retainedFileTimeLimit = null) + TimeSpan? retainedFileTimeLimit = null, + bool keepPathStaticOnRoll = false, + Func? customFormatFunc = null, + string? customRollPattern = null) { if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -339,7 +354,7 @@ public static LoggerConfiguration File( 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); } /// @@ -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( @@ -513,7 +529,10 @@ static LoggerConfiguration ConfigureFile( bool rollOnFileSizeLimit, int? retainedFileCountLimit, FileLifecycleHooks? hooks, - TimeSpan? retainedFileTimeLimit) + TimeSpan? retainedFileTimeLimit, + bool keepPathStaticOnRoll, + Func? customFormatFunc, + string? customRollPattern) { if (addSink == null) throw new ArgumentNullException(nameof(addSink)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -523,6 +542,7 @@ 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; @@ -530,7 +550,7 @@ static LoggerConfiguration ConfigureFile( { 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 { diff --git a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs index 32c0cd3..1e29d17 100644 --- a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs @@ -44,6 +44,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene /// Character encoding used to write the text file. The default is UTF-8 without BOM. /// Indicates if flushing to the output file can be buffered or not. The default /// is false. + /// Indicates that the path is static/persistent /// Configuration object allowing method chaining. /// This constructor preserves compatibility with early versions of the public API. New code should not depend on this type. /// When is null @@ -56,7 +57,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene /// The caller does not have the required permission to access the /// Invalid [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) { } @@ -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."); @@ -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) @@ -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 { diff --git a/src/Serilog.Sinks.File/Sinks/File/PathRoller.cs b/src/Serilog.Sinks.File/Sinks/File/PathRoller.cs index e6773eb..54a4bf9 100644 --- a/src/Serilog.Sinks.File/Sinks/File/PathRoller.cs +++ b/src/Serilog.Sinks.File/Sinks/File/PathRoller.cs @@ -26,15 +26,29 @@ sealed class PathRoller readonly string _filenamePrefix; readonly string _filenameSuffix; readonly Regex _filenameMatcher; + readonly bool _keepPathStatic; + readonly string? _customRollPattern; + private Func? _customFormatFunc; readonly RollingInterval _interval; readonly string _periodFormat; - public PathRoller(string path, RollingInterval interval) + public PathRoller(string path, RollingInterval interval, bool keepPathStatic = false, Func? 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)) @@ -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? 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); } @@ -110,4 +201,16 @@ public IEnumerable SelectMatches(IEnumerable 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)); + } + } } diff --git a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs index 93c02c5..b107670 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +using System.Globalization; using System.Text; using Serilog.Core; using Serilog.Debugging; @@ -31,7 +32,9 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable, I readonly bool _buffered; readonly bool _shared; readonly bool _rollOnFileSizeLimit; + readonly bool _keepPathAsStaticFile; readonly FileLifecycleHooks? _hooks; + readonly Func? _customFormatFunc; ILoggingFailureListener _failureListener = SelfLog.FailureListener; @@ -51,14 +54,18 @@ public RollingFileSink(string path, RollingInterval rollingInterval, bool rollOnFileSizeLimit, FileLifecycleHooks? hooks, - TimeSpan? retainedFileTimeLimit) + TimeSpan? retainedFileTimeLimit, + bool keepPathAsStaticFile, + Func? customFormatFunc, + string? customRollPattern) { 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."); if (retainedFileCountLimit is < 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)); + if(customFormatFunc != null && customRollPattern == null) throw new ArgumentException("When Supplying a Custom Format Function, a Custom Roll Pattern must also be supplied."); - _roller = new PathRoller(path, rollingInterval); + _roller = new PathRoller(path, rollingInterval, keepPathAsStaticFile, customFormatFunc, customRollPattern); _textFormatter = textFormatter; _fileSizeLimitBytes = fileSizeLimitBytes; _retainedFileCountLimit = retainedFileCountLimit; @@ -68,6 +75,8 @@ public RollingFileSink(string path, _shared = shared; _rollOnFileSizeLimit = rollOnFileSizeLimit; _hooks = hooks; + _keepPathAsStaticFile = keepPathAsStaticFile; + _customFormatFunc = customFormatFunc; } public void Emit(LogEvent logEvent) @@ -163,10 +172,37 @@ void OpenFile(DateTime now, int? minSequence = null) sequence = minSequence; } + if (sequence != null) + { + _roller.GetLogFilePath(now, sequence, out var p, out var c); + switch (_keepPathAsStaticFile) + { + // var path = Path.Combine(_roller.LogFileDirectory, $"{_roller.PathRollerPrefix}{_customFormatFunc?.Invoke()}_{sequence.Value.ToString("000", CultureInfo.InvariantCulture)}{_roller.DirectorySearchPattern}"); + case true when System.IO.File.Exists(c): + sequence++; + break; + case false when _customFormatFunc != null && !System.IO.File.Exists(p): + sequence = 1; + break; + } + } + const int maxAttempts = 3; for (var attempt = 0; attempt < maxAttempts; attempt++) { - _roller.GetLogFilePath(now, sequence, out var path); + string path; + if (_keepPathAsStaticFile) + { + _roller.GetLogFilePath(now, sequence, out path, out var copyPath); + if (copyPath != null && System.IO.File.Exists(path)) + { + System.IO.File.Copy(path, copyPath); + } + } + else + { + _roller.GetLogFilePath(now, sequence, out path); + } try { @@ -176,7 +212,7 @@ void OpenFile(DateTime now, int? minSequence = null) new SharedFileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding) : #pragma warning restore 618 - new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks); + new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks, _keepPathAsStaticFile); _currentFileSequence = sequence; diff --git a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs index 191e614..f00e604 100644 --- a/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/RollingFileSinkTests.cs @@ -1,4 +1,5 @@ using System.IO.Compression; +using System.Text.RegularExpressions; using Xunit; using Serilog.Events; using Serilog.Sinks.File.Tests.Support; @@ -293,6 +294,163 @@ public void WhenSizeLimitIsBreachedNewFilesCreated() Assert.True(files[2].EndsWith("_002.txt"), files[2]); } + [Fact] + public void WhenCustomRollingEnabledItUsesThePattern() + { + string CustomStringOut(DateTime? time) + { + return $"_{time??DateTime.Now:yyyy-MM-dd-HH-mm-ss}"; + } + + var regexPattern = @"_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}"; + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + using var log = new LoggerConfiguration() + .WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true, + fileSizeLimitBytes: 40, customFormatFunc: CustomStringOut, + customRollPattern: regexPattern) + .CreateLogger(); + LogEvent e1 = Some.InformationEvent(), + e2 = Some.InformationEvent(e1.Timestamp), + e3 = Some.InformationEvent(e1.Timestamp), + e4 = Some.InformationEvent(e1.Timestamp), + e5 = Some.InformationEvent(e1.Timestamp); + log.Write(e1); + log.Write(e2); + log.Write(e3); + log.Write(e4); + log.Write(e5); + var files = Directory.GetFiles(temp.Path) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(5, files.Length); + + Assert.True(files[0].EndsWith(fileName), files[0]); + foreach (var file in files.Skip(1)) + { + Assert.True(Regex.IsMatch(file, regexPattern), file); + } + } + + [Fact] + public void WhenCustomRollingEnabledAndTimeIntervalItUsesThePattern() + { + string CustomStringOut(DateTime? time = null) + { + var t = time ?? DateTime.Now; + return $"_{t:yyyy-MM-dd-HH-mm-ss}"; + } + + var regexPattern = @"_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}"; + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + LogEvent e1 = Some.InformationEvent(), + e2 = Some.InformationEvent(e1.Timestamp.AddDays(2)), + e3 = Some.InformationEvent(e2.Timestamp.AddDays(2)); + TestRollingEventSequence((pf, wt) => wt.File(pf, + rollingInterval: RollingInterval.Minute, + customFormatFunc: CustomStringOut, + customRollPattern: regexPattern), + 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])); + foreach (var file in files.Skip(1)) + { + Assert.True(Regex.IsMatch(file, regexPattern), file); + } + }, + fileNameOverride: fileName, + patternOverride:"_yyyy-MM-dd-HH-mm-ss"); + } + + [Fact] + public void WhenStaticEnabledPathBasePathIsLastUpdated() + { + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + using var log = new LoggerConfiguration() + .WriteTo.File(Path.Combine(temp.Path, fileName), rollOnFileSizeLimit: true, + fileSizeLimitBytes: 40, keepPathStaticOnRoll: true) + .CreateLogger(); + LogEvent e1 = Some.InformationEvent(), + e2 = Some.InformationEvent(e1.Timestamp), + e3 = Some.InformationEvent(e1.Timestamp), + e4 = Some.InformationEvent(e1.Timestamp), + e5 = Some.InformationEvent(e1.Timestamp); + log.Write(e1); + //Sleep here so that LastModifiedTime of the file can be different + Thread.Sleep(1000); + log.Write(e2); + Thread.Sleep(1000); + log.Write(e3); + var files = Directory.GetFiles(temp.Path) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(3, files.Length); + + Assert.True(files[0].EndsWith(fileName), files[0]); + Assert.True(files[1].EndsWith("_001.txt"), files[1]); + Assert.True(files[2].EndsWith("_002.txt"), files[2]); + var lastModifiedFile = files + .Select(f => new FileInfo(f)) + .OrderByDescending(f => f.LastWriteTime) + .First(); + // Using the full path can result in failures with a space in the User folder name + Assert.Equal(Path.GetFileName(files[0]), Path.GetFileName(lastModifiedFile.FullName)); + } + + [Fact] + public void WhenStaticEnabledAndCustomRollPathBasePathIsLastUpdated() + { + string CustomStringOut(DateTime? time = null) + { + return $"_{time??DateTime.Now:yyyy-MM-dd-HH-mm-ss}"; + } + + var regexPattern = @"_\d{4}-\d{2}-\d{2}-\d{2}-\d{2}-\d{2}"; + var fileName = Some.String() + ".txt"; + using var temp = new TempFolder(); + using var log = new LoggerConfiguration() + .WriteTo.File(Path.Combine(temp.Path, fileName), + rollOnFileSizeLimit: true, + fileSizeLimitBytes: 40, + customFormatFunc: CustomStringOut, + customRollPattern: regexPattern, + keepPathStaticOnRoll: true) + .CreateLogger(); + LogEvent e1 = Some.InformationEvent(), + e2 = Some.InformationEvent(e1.Timestamp), + e3 = Some.InformationEvent(e1.Timestamp); + log.Write(e1); + Thread.Sleep(1000); + log.Write(e2); + Thread.Sleep(1001); + log.Write(e3); + var files = Directory.GetFiles(temp.Path) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase) + .ToArray(); + + Assert.Equal(3, files.Length); + + Assert.True(files[0].EndsWith(fileName), files[0]); + foreach (var file in files.Skip(1)) + { + Assert.True(Regex.IsMatch(file, regexPattern), file); + } + var lastModifiedFile = files + .Select(f => new FileInfo(f)) + .OrderByDescending(f => f.LastWriteTime) + .First(); + // Using the full path can result in failures with a space in the User folder name + Assert.Equal(Path.GetFileName(files[0]), Path.GetFileName(lastModifiedFile.FullName)); + } + [Fact] public void WhenStreamWrapperSpecifiedIsUsedForRolledFiles() { @@ -384,11 +542,14 @@ static void TestRollingEventSequence(params LogEvent[] events) static void TestRollingEventSequence( Action configureFile, IEnumerable events, - Action>? verifyWritten = null) + Action>? verifyWritten = null, + string? fileNameOverride = null, + string? patternOverride = null) { - var fileName = Some.String() + "-.txt"; - var folder = Some.TempFolderPath(); + var fileName = fileNameOverride ?? Some.String() + "-.txt"; + var folder = new TempFolder().Path; var pathFormat = Path.Combine(folder, fileName); + var pattern = patternOverride ?? "yyyyMMdd"; var config = new LoggerConfiguration(); configureFile(pathFormat, config.WriteTo); @@ -402,8 +563,7 @@ static void TestRollingEventSequence( { Clock.SetTestDateTimeNow(@event.Timestamp.DateTime); log.Write(@event); - - var expected = ExpectedFileName(pathFormat, @event.Timestamp, "yyyyMMdd"); + var expected = ExpectedFileName(pathFormat, @event.Timestamp, pattern); Assert.True(System.IO.File.Exists(expected)); verified.Add(expected); From ece8bbbfabdeafe334b452657fb23b579f14951e Mon Sep 17 00:00:00 2001 From: Justin Moore Date: Mon, 17 Mar 2025 15:05:49 -0700 Subject: [PATCH 2/2] Move file rather than copy. --- src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs index b107670..21dc962 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -196,7 +196,7 @@ void OpenFile(DateTime now, int? minSequence = null) _roller.GetLogFilePath(now, sequence, out path, out var copyPath); if (copyPath != null && System.IO.File.Exists(path)) { - System.IO.File.Copy(path, copyPath); + System.IO.File.Move(path, copyPath); } } else