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 1/4] 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]; + } } From 16c634d64aec51ec5c7732321e610d83d54ebbce Mon Sep 17 00:00:00 2001 From: BernhardMarconato <37772769+BernhardMarconato@users.noreply.github.com> Date: Thu, 13 Nov 2025 20:38:03 +0100 Subject: [PATCH 2/4] Revert "Allow the sink to flush for events of a minimum log level" This reverts commit 9f05a09754c6554fe1153f447d05babf8ebaf295. --- .../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, 16 insertions(+), 122 deletions(-) diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index 9c0fb84..e3e8bcf 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -306,7 +306,6 @@ 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 @@ -332,8 +331,7 @@ public static LoggerConfiguration File( int? retainedFileCountLimit = DefaultRetainedFileCountLimit, Encoding? encoding = null, FileLifecycleHooks? hooks = null, - TimeSpan? retainedFileTimeLimit = null, - LogEventLevel flushAtMinimumLevel = LevelAlias.Off) + TimeSpan? retainedFileTimeLimit = null) { if (sinkConfiguration == null) throw new ArgumentNullException(nameof(sinkConfiguration)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -341,7 +339,7 @@ public static LoggerConfiguration File( return ConfigureFile(sinkConfiguration.Sink, formatter, path, restrictedToMinimumLevel, fileSizeLimitBytes, levelSwitch, buffered, false, shared, flushToDiskInterval, encoding, rollingInterval, rollOnFileSizeLimit, - retainedFileCountLimit, hooks, retainedFileTimeLimit, flushAtMinimumLevel); + retainedFileCountLimit, hooks, retainedFileTimeLimit); } /// @@ -496,7 +494,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, LevelAlias.Off); + false, null, encoding, RollingInterval.Infinite, false, null, hooks, null); } static LoggerConfiguration ConfigureFile( @@ -515,8 +513,7 @@ static LoggerConfiguration ConfigureFile( bool rollOnFileSizeLimit, int? retainedFileCountLimit, FileLifecycleHooks? hooks, - TimeSpan? retainedFileTimeLimit, - LogEventLevel flushAtMinimumLevel) + TimeSpan? retainedFileTimeLimit) { if (addSink == null) throw new ArgumentNullException(nameof(addSink)); if (formatter == null) throw new ArgumentNullException(nameof(formatter)); @@ -533,7 +530,7 @@ static LoggerConfiguration ConfigureFile( { if (rollOnFileSizeLimit || rollingInterval != RollingInterval.Infinite) { - sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit, flushAtMinimumLevel); + sink = new RollingFileSink(path, formatter, fileSizeLimitBytes, retainedFileCountLimit, encoding, buffered, shared, rollingInterval, rollOnFileSizeLimit, hooks, retainedFileTimeLimit); } else { @@ -545,7 +542,7 @@ static LoggerConfiguration ConfigureFile( } else { - sink = new FileSink(path, formatter, fileSizeLimitBytes, encoding, buffered, hooks, flushAtMinimumLevel); + sink = new FileSink(path, formatter, fileSizeLimitBytes, encoding, buffered, hooks); } } diff --git a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs index ed3cc08..32c0cd3 100644 --- a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs @@ -32,7 +32,6 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene readonly bool _buffered; readonly object _syncRoot = new(); readonly WriteCountingStream? _countingStreamWrapper; - readonly LogEventLevel _flushAtMinimumLevel; ILoggingFailureListener _failureListener = SelfLog.FailureListener; @@ -58,7 +57,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, LevelAlias.Off) + : this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null) { } @@ -69,15 +68,13 @@ internal FileSink( long? fileSizeLimitBytes, Encoding? encoding, bool buffered, - FileLifecycleHooks? hooks, - LogEventLevel flushAtMinimumLevel) + FileLifecycleHooks? hooks) { 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)) @@ -127,8 +124,6 @@ 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 762bac2..93c02c5 100644 --- a/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs @@ -27,7 +27,6 @@ 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; @@ -52,8 +51,7 @@ public RollingFileSink(string path, RollingInterval rollingInterval, bool rollOnFileSizeLimit, FileLifecycleHooks? hooks, - TimeSpan? retainedFileTimeLimit, - LogEventLevel flushAtMinimumLevel) + TimeSpan? retainedFileTimeLimit) { 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."); @@ -65,7 +63,6 @@ public RollingFileSink(string path, _fileSizeLimitBytes = fileSizeLimitBytes; _retainedFileCountLimit = retainedFileCountLimit; _retainedFileTimeLimit = retainedFileTimeLimit; - _flushAtMinimumLevel = flushAtMinimumLevel; _encoding = encoding; _buffered = buffered; _shared = shared; @@ -179,7 +176,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, _flushAtMinimumLevel); + new FileSink(path, _textFormatter, _fileSizeLimitBytes, _encoding, _buffered, _hooks); _currentFileSequence = sequence; diff --git a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs index be45a8f..b42a562 100644 --- a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs @@ -1,7 +1,6 @@ -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; @@ -147,7 +146,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, LevelAlias.Off)) + using (var sink = new FileSink(path, new JsonFormatter(), null, null, false, gzipWrapper)) { sink.Emit(evt); sink.Emit(evt); @@ -179,12 +178,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, LevelAlias.Off)) + using (new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter)) { // Open and write header } - using (var sink = new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter, LevelAlias.Off)) + using (var sink = new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, headerWriter)) { // Length check should prevent duplicate header here sink.Emit(Some.LogEvent()); @@ -204,7 +203,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, LevelAlias.Off)) + using (new FileSink(path, new JsonFormatter(), null, new UTF8Encoding(false), false, capturePath)) { // Open and capture the log file path } @@ -224,7 +223,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, LevelAlias.Off)) + using (var sink = new FileSink(path, new JsonFormatter(), fileSizeLimitBytes: null, encoding: new UTF8Encoding(false), buffered: false, hooks: emptyFileHook)) { // Hook will clear the contents of the file before emitting the log events sink.Emit(Some.LogEvent()); @@ -236,83 +235,6 @@ 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(); @@ -338,21 +260,4 @@ 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]; - } } From 957a5b0e98f2a2bb2fc9e0a632ca015743aff4d5 Mon Sep 17 00:00:00 2001 From: BernhardMarconato <37772769+BernhardMarconato@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:02:16 +0100 Subject: [PATCH 3/4] Always flush sink on Fatal events --- README.md | 2 +- .../FileLoggerConfigurationExtensions.cs | 24 +++++----- src/Serilog.Sinks.File/Sinks/File/FileSink.cs | 5 +- .../Serilog.Sinks.File.Tests/FileSinkTests.cs | 46 +++++++++++++++++-- 4 files changed, 60 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 1feed22..9eec1d2 100644 --- a/README.md +++ b/README.md @@ -189,7 +189,7 @@ Only a limited subset of configuration options are currently available in this m ### Performance -By default, the file sink will flush each event written through it to disk. To improve write performance, specifying `buffered: true` will permit the underlying stream to buffer writes. +By default, the file sink will flush each event written through it to disk. To improve write performance, specifying `buffered: true` will permit the underlying stream to buffer writes. However, events with `LogEventLevel.Fatal` will always be flushed to disk immediately. The [Serilog.Sinks.Async](https://github.com/serilog/serilog-sinks-async) package can be used to wrap the file sink and perform all disk access on a background worker thread. diff --git a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs index e3e8bcf..d75124b 100644 --- a/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs +++ b/src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs @@ -49,8 +49,8 @@ public static class FileLoggerConfigurationExtensions /// The approximate maximum size, in bytes, to which a log file will be allowed to grow. /// For unrestricted growth, pass null. The default is 1 GB. To avoid writing partial events, the last event within the limit /// will be written in full even if it exceeds the limit. - /// Indicates if flushing to the output file can be buffered or not. The default - /// is false. + /// Indicates if flushing of non-fatal events to the output file + /// can be buffered or not. The default is false. /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// Configuration object allowing method chaining. @@ -89,8 +89,8 @@ public static LoggerConfiguration File( /// The approximate maximum size, in bytes, to which a log file will be allowed to grow. /// For unrestricted growth, pass null. The default is 1 GB. To avoid writing partial events, the last event within the limit /// will be written in full even if it exceeds the limit. - /// Indicates if flushing to the output file can be buffered or not. The default - /// is false. + /// Indicates if flushing of non-fatal events to the output file + /// can be buffered or not. The default is false. /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// Configuration object allowing method chaining. @@ -126,8 +126,8 @@ public static LoggerConfiguration File( /// The approximate maximum size, in bytes, to which a log file will be allowed to grow. /// For unrestricted growth, pass null. The default is 1 GB. To avoid writing partial events, the last event within the limit /// will be written in full even if it exceeds the limit. - /// Indicates if flushing to the output file can be buffered or not. The default - /// is false. + /// Indicates if flushing of non-fatal events to the output file + /// can be buffered or not. The default is false. /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// The interval at which logging will roll over to a new file. @@ -175,8 +175,8 @@ public static LoggerConfiguration File( /// The approximate maximum size, in bytes, to which a log file will be allowed to grow. /// For unrestricted growth, pass null. The default is 1 GB. To avoid writing partial events, the last event within the limit /// will be written in full even if it exceeds the limit. - /// Indicates if flushing to the output file can be buffered or not. The default - /// is false. + /// Indicates if flushing of non-fatal events to the output file + /// can be buffered or not. The default is false. /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// The interval at which logging will roll over to a new file. @@ -221,8 +221,8 @@ public static LoggerConfiguration File( /// The approximate maximum size, in bytes, to which a log file will be allowed to grow. /// For unrestricted growth, pass null. The default is 1 GB. To avoid writing partial events, the last event within the limit /// will be written in full even if it exceeds the limit. - /// Indicates if flushing to the output file can be buffered or not. The default - /// is false. + /// Indicates if flushing of non-fatal events to the output file + /// can be buffered or not. The default is false. /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// The interval at which logging will roll over to a new file. @@ -291,8 +291,8 @@ public static LoggerConfiguration File( /// The approximate maximum size, in bytes, to which a log file will be allowed to grow. /// For unrestricted growth, pass null. The default is 1 GB. To avoid writing partial events, the last event within the limit /// will be written in full even if it exceeds the limit. - /// Indicates if flushing to the output file can be buffered or not. The default - /// is false. + /// Indicates if flushing of non-fatal events to the output file + /// can be buffered or not. The default is false. /// Allow the log file to be shared by multiple processes. The default is false. /// If provided, a full disk flush will be performed periodically at the specified interval. /// The interval at which logging will roll over to a new file. diff --git a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs index 32c0cd3..14c9f56 100644 --- a/src/Serilog.Sinks.File/Sinks/File/FileSink.cs +++ b/src/Serilog.Sinks.File/Sinks/File/FileSink.cs @@ -122,7 +122,10 @@ bool IFileSink.EmitOrOverflow(LogEvent logEvent) } _textFormatter.Format(logEvent, _output); - if (!_buffered) + + if (logEvent.Level == LogEventLevel.Fatal) + FlushToDisk(); + else if (!_buffered) _output.Flush(); return true; diff --git a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs index b42a562..fa7ea49 100644 --- a/test/Serilog.Sinks.File.Tests/FileSinkTests.cs +++ b/test/Serilog.Sinks.File.Tests/FileSinkTests.cs @@ -1,10 +1,11 @@ -using System.IO.Compression; -using System.Text; using Serilog.Core; -using Xunit; +using Serilog.Events; using Serilog.Formatting.Json; using Serilog.Sinks.File.Tests.Support; using Serilog.Tests.Support; +using System.IO.Compression; +using System.Text; +using Xunit; #pragma warning disable 618 @@ -235,6 +236,28 @@ public static void OnOpenedLifecycleHookCanEmptyTheFileContents() Assert.Equal('{', lines[0][0]); } + [Fact] + public void WhenBufferedFatalEventFlushesAllPendingEvents() + { + using var tmp = TempFolder.ForCaller(); + var path = tmp.AllocateFilename("txt"); + var formatter = new JsonFormatter(); + + using (var sink = new FileSink(path, formatter, null, null, true)) + { + sink.Emit(Some.LogEvent(level: LogEventLevel.Information)); + sink.Emit(Some.LogEvent(level: LogEventLevel.Warning)); + + var lines = ReadAllLinesShared(path); + Assert.Empty(lines); + + sink.Emit(Some.LogEvent(level: LogEventLevel.Fatal)); + + lines = ReadAllLinesShared(path); + Assert.Equal(3, lines.Length); + } + } + static void WriteTwoEventsAndCheckOutputFileLength(long? maxBytes, Encoding encoding) { using var tmp = TempFolder.ForCaller(); @@ -260,4 +283,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]; + } } From 558da59d8ef5651a5e7d7c5e8554c7a49cc5ffc4 Mon Sep 17 00:00:00 2001 From: BernhardMarconato <37772769+BernhardMarconato@users.noreply.github.com> Date: Thu, 13 Nov 2025 21:03:03 +0100 Subject: [PATCH 4/4] Bump version to 8.0 --- Directory.Version.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Version.props b/Directory.Version.props index 8596ae5..2e9cd58 100644 --- a/Directory.Version.props +++ b/Directory.Version.props @@ -1,5 +1,5 @@ - 7.0.1 + 8.0.0