Skip to content
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
15 changes: 9 additions & 6 deletions src/Serilog.Sinks.File/FileLoggerConfigurationExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,7 @@ 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="flushAtMinimumLevel">The minimum level for events to flush the sink. The default is <see cref="LevelAlias.Off"/>.</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 +332,16 @@ 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));
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, flushAtMinimumLevel);
}

/// <summary>
Expand Down Expand Up @@ -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(
Expand All @@ -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));
Expand All @@ -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
{
Expand All @@ -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);
}

}
Expand Down
9 changes: 7 additions & 2 deletions src/Serilog.Sinks.File/Sinks/File/FileSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -57,7 +58,7 @@ public sealed class FileSink : IFileSink, IDisposable, ISetLoggingFailureListene
/// <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)
: this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null)
: this(path, textFormatter, fileSizeLimitBytes, encoding, buffered, null, LevelAlias.Off)
{
}

Expand All @@ -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))
Expand Down Expand Up @@ -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;
}
Expand Down
7 changes: 5 additions & 2 deletions src/Serilog.Sinks.File/Sinks/File/RollingFileSink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.");
Expand All @@ -63,6 +65,7 @@ public RollingFileSink(string path,
_fileSizeLimitBytes = fileSizeLimitBytes;
_retainedFileCountLimit = retainedFileCountLimit;
_retainedFileTimeLimit = retainedFileTimeLimit;
_flushAtMinimumLevel = flushAtMinimumLevel;
_encoding = encoding;
_buffered = buffered;
_shared = shared;
Expand Down Expand Up @@ -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;

Expand Down
107 changes: 101 additions & 6 deletions test/Serilog.Sinks.File.Tests/FileSinkTests.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -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
}
Expand All @@ -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());
Expand All @@ -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();
Expand All @@ -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<string> lines = [];

while ((line = reader.ReadLine()) != null)
{
lines.Add(line);
}

return [.. lines];
}
}