From 9f05a09754c6554fe1153f447d05babf8ebaf295 Mon Sep 17 00:00:00 2001 From: BernhardMarconato <37772769+BernhardMarconato@users.noreply.github.com> Date: Sun, 2 Nov 2025 17:28:24 +0100 Subject: [PATCH] Allow the sink to flush for events of a minimum log level --- .../FileLoggerConfigurationExtensions.cs | 15 ++- src/Serilog.Sinks.File/Sinks/File/FileSink.cs | 9 +- .../Sinks/File/RollingFileSink.cs | 7 +- .../Serilog.Sinks.File.Tests/FileSinkTests.cs | 107 +++++++++++++++++- 4 files changed, 122 insertions(+), 16 deletions(-) diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index e3e8bcf..9c0fb84 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -306,6 +306,7 @@ public static LoggerConfiguration File( /// Must be greater than or equal to . /// Ignored if is . /// The default is to retain files indefinitely. + /// The minimum level for events to flush the sink. The default is . /// Configuration object allowing method chaining. /// When is null /// When is null @@ -331,7 +332,8 @@ public static LoggerConfiguration File( int? retainedFileCountLimit = DefaultRetainedFileCountLimit, Encoding? encoding = null, FileLifecycleHooks? hooks = null, - TimeSpan? retainedFileTimeLimit = null) + TimeSpan? retainedFileTimeLimit = null, + LogEventLevel flushAtMinimumLevel = LevelAlias.Off) { if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -339,7 +341,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, flushAtMinimumLevel); } /// @@ -494,7 +496,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, null); + false, null, encoding, RollingInterval.Infinite, false, null, hooks, null, LevelAlias.Off); } static LoggerConfiguration ConfigureFile( @@ -513,7 +515,8 @@ static LoggerConfiguration ConfigureFile( bool rollOnFileSizeLimit, int? retainedFileCountLimit, FileLifecycleHooks? hooks, - TimeSpan? retainedFileTimeLimit) + TimeSpan? retainedFileTimeLimit, + LogEventLevel flushAtMinimumLevel) { if (addSink == null) throw new ArgumentNullException(nameof(addSink)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -530,7 +533,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, flushAtMinimumLevel); } else { @@ -542,7 +545,7 @@ static LoggerConfiguration ConfigureFile( } else { - sink = new FileSink(path, formatter, fileSizeLimitBytes, encoding, buffered, hooks); + sink = new FileSink(path, formatter, fileSizeLimitBytes, encoding, buffered, hooks, flushAtMinimumLevel); } } diff --git a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs index 32c0cd3..ed3cc08 100644 --- a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs @@ -32,6 +32,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene readonly bool _buffered; readonly object _syncRoot = new(); readonly WriteCountingStream? _countingStreamWrapper; + readonly LogEventLevel _flushAtMinimumLevel; ILoggingFailureListener _failureListener = SelfLog.FailureListener; @@ -57,7 +58,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene /// 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) - : this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null) + : this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null, LevelAlias.Off) { } @@ -68,13 +69,15 @@ internal FileSink( long? fileSizeLimitBytes, Encoding? encoding, bool buffered, - FileLifecycleHooks? hooks) + FileLifecycleHooks? hooks, + LogEventLevel flushAtMinimumLevel) { 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."); _textFormatter = textFormatter ?? throw new ArgumentNullException(nameof(textFormatter)); _fileSizeLimitBytes = fileSizeLimitBytes; _buffered = buffered; + _flushAtMinimumLevel = flushAtMinimumLevel; var directory = Path.GetDirectoryName(path); if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory)) @@ -124,6 +127,8 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) _textFormatter.Format(logEvent, _output); if (!_buffered) _output.Flush(); + else if (logEvent.Level >= _flushAtMinimumLevel) + FlushToDisk(); return true; } diff --git a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs index 93c02c5..762bac2 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -27,6 +27,7 @@ sealed class RollingFileSink : ILogEventSink, IFlushableFileSink, IDisposable, I readonly long? _fileSizeLimitBytes; readonly int? _retainedFileCountLimit; readonly TimeSpan? _retainedFileTimeLimit; + readonly LogEventLevel _flushAtMinimumLevel; readonly Encoding? _encoding; readonly bool _buffered; readonly bool _shared; @@ -51,7 +52,8 @@ public RollingFileSink(string path, RollingInterval rollingInterval, bool rollOnFileSizeLimit, FileLifecycleHooks? hooks, - TimeSpan? retainedFileTimeLimit) + TimeSpan? retainedFileTimeLimit, + LogEventLevel flushAtMinimumLevel) { 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."); @@ -63,6 +65,7 @@ public RollingFileSink(string path, _fileSizeLimitBytes = fileSizeLimitBytes; _retainedFileCountLimit = retainedFileCountLimit; _retainedFileTimeLimit = retainedFileTimeLimit; + _flushAtMinimumLevel = flushAtMinimumLevel; _encoding = encoding; _buffered = buffered; _shared = shared; @@ -176,7 +179,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, _flushAtMinimumLevel); _currentFileSequence = sequence; diff --git a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs index b42a562..be45a8f 100644 --- a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs @@ -1,6 +1,7 @@ -using System.IO.Compression; +using System.IO.Compression; using System.Text; using Serilog.Core; +using Serilog.Events; using Xunit; using Serilog.Formatting.Json; using Serilog.Sinks.File.Tests.Support; @@ -146,7 +147,7 @@ public void OnOpenedLifecycleHookCanWrapUnderlyingStream() var path = tmp.AllocateFilename("txt"); var evt = Some.LogEvent("Hello, world!"); - using (var sink = new FileSink(path, new JsonFormatter(), null, null, false, gzipWrapper)) + using (var sink = new FileSink(path, new JsonFormatter(), null, null, false, gzipWrapper, LevelAlias.Off)) { sink.Emit(evt); sink.Emit(evt); @@ -178,12 +179,12 @@ public static void OnOpenedLifecycleHookCanWriteFileHeader() var headerWriter = new FileHeaderWriter("This is the file header"); var path = tmp.AllocateFilename("txt"); - using (new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter)) + using (new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter, LevelAlias.Off)) { // Open and write header } - using (var sink = new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter)) + using (var sink = new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter, LevelAlias.Off)) { // Length check should prevent duplicate header here sink.Emit(Some.LogEvent()); @@ -203,7 +204,7 @@ public static void OnOpenedLifecycleHookCanCaptureFilePath() var capturePath = new CaptureFilePathHook(); var path = tmp.AllocateFilename("txt"); - using (new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, capturePath)) + using (new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, capturePath, LevelAlias.Off)) { // Open and capture the log file path } @@ -223,7 +224,7 @@ public static void OnOpenedLifecycleHookCanEmptyTheFileContents() sink.Emit(Some.LogEvent()); } - using (var sink = new FileSink(path, new JsonFormatter(), fileSizeLimitBytes: null, encoding: new UTF8Encoding(false), buffered: false, hooks: emptyFileHook)) + using (var sink = new FileSink(path, new JsonFormatter(), fileSizeLimitBytes: null, encoding: new UTF8Encoding(false), buffered: false, hooks: emptyFileHook, LevelAlias.Off)) { // Hook will clear the contents of the file before emitting the log events sink.Emit(Some.LogEvent()); @@ -235,6 +236,83 @@ public static void OnOpenedLifecycleHookCanEmptyTheFileContents() Assert.Equal('{', lines[0][0]); } + [Fact] + public void WhenFlushAtMinimumLevelIsNotReachedLineIsNotFlushed() + { + using var tmp = TempFolder.ForCaller(); + var path = tmp.AllocateFilename("txt"); + var formatter = new JsonFormatter(); + + using (var sink = new FileSink(path, formatter, null, null, true, null, LogEventLevel.Fatal)) + { + sink.Emit(Some.LogEvent(level: LogEventLevel.Information)); + + var lines = ReadAllLinesShared(path); + Assert.Empty(lines); + } + + var savedLines = System.IO.File.ReadAllLines(path); + Assert.Single(savedLines); + } + + [Fact] + public void WhenFlushAtMinimumLevelIsReachedLineIsFlushed() + { + using var tmp = TempFolder.ForCaller(); + var path = tmp.AllocateFilename("txt"); + var formatter = new JsonFormatter(); + + using (var sink = new FileSink(path, formatter, null, null, true, null, LogEventLevel.Fatal)) + { + sink.Emit(Some.LogEvent(level: LogEventLevel.Fatal)); + + var lines = ReadAllLinesShared(path); + Assert.Single(lines); + } + + var savedLines = System.IO.File.ReadAllLines(path); + Assert.Single(savedLines); + } + + [Fact] + public void WhenFlushAtMinimumLevelIsOffLineIsNotFlushed() + { + using var tmp = TempFolder.ForCaller(); + var path = tmp.AllocateFilename("txt"); + var formatter = new JsonFormatter(); + + using (var sink = new FileSink(path, formatter, null, null, true, null, LevelAlias.Off)) + { + sink.Emit(Some.LogEvent(level: LogEventLevel.Fatal)); + + var lines = ReadAllLinesShared(path); + Assert.Empty(lines); + } + + var savedLines = System.IO.File.ReadAllLines(path); + Assert.Single(savedLines); + } + + [Fact] + public void WhenFlushAtMinimumLevelIsReachedMultipleLinesAreFlushed() + { + using var tmp = TempFolder.ForCaller(); + var path = tmp.AllocateFilename("txt"); + var formatter = new JsonFormatter(); + + using (var sink = new FileSink(path, formatter, null, null, true, null, LogEventLevel.Error)) + { + sink.Emit(Some.LogEvent(level: LogEventLevel.Information)); + sink.Emit(Some.LogEvent(level: LogEventLevel.Fatal)); + + var lines = ReadAllLinesShared(path); + Assert.Equal(2, lines.Length); + } + + var savedLines = System.IO.File.ReadAllLines(path); + Assert.Equal(2, savedLines.Length); + } + static void WriteTwoEventsAndCheckOutputFileLength(long? maxBytes, Encoding encoding) { using var tmp = TempFolder.ForCaller(); @@ -260,4 +338,21 @@ static void WriteTwoEventsAndCheckOutputFileLength(long? maxBytes, Encoding enco size = new FileInfo(path).Length; Assert.Equal(encoding.GetPreamble().Length + eventOuputLength * 2, size); } + + private static string[] ReadAllLinesShared(string path) + { + // ReadAllLines cannot be used here, as it can't read files even if they are opened with FileShare.Read + using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + using var reader = new StreamReader(fs); + + string? line; + List lines = []; + + while ((line = reader.ReadLine()) != null) + { + lines.Add(line); + } + + return [.. lines]; + } }