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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ The log file settings below can be specified globally (at provider level) and in
| **IncludeScopes** | Enables log scopes to be included in the output. | `false` | Works the same way as `ConsoleLogger`. |
| **MaxQueueSize** | Defines the maximum capacity of the log processor queue (per file). | `0` (unbounded) | If set to a value greater than 0, log entries will be discarded when the queue is full, that is, when the specified limit is exceeded. |
| **PathPlaceholderResolver** | Provides a way to hook into path template resolution. | | This is a callback that can be used to customize or extend the resolution of path template placeholders. Enables special formatting, custom placeholders, etc.<br/>For an example of usage, see [this sample application](https://github.com/adams85/filelogger/tree/master/samples/CustomPathPlaceholder). |
| **SynchronousWrite** | When enabled, log entries are written directly to the file on the calling thread, bypassing the background queue. | `false` | Guarantees that log entries are persisted before `ILogger.Log` returns, which is useful for crash-critical diagnostics. **Blocks the calling thread** during file I/O, so it reduces throughput. Can be combined with any `FileAccessMode`. |

### Sample JSON configuration
``` json5
Expand All @@ -294,6 +295,7 @@ The log file settings below can be specified globally (at provider level) and in
},
"IncludeScopes": true,
"MaxQueueSize": 100,
"SynchronousWrite": false,
"Files": [
// a simple log file definition, which inherits all settings from the provider (will produce files like "default-000.log")
{
Expand Down
6 changes: 5 additions & 1 deletion source/FileLogger/FileLogger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,11 @@ public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Except
break;
}

Processor.Enqueue(entry, fileSettings, currentState.Settings);
bool synchronousWrite = fileSettings.SynchronousWrite ?? currentState.Settings.SynchronousWrite ?? false;
if (synchronousWrite)
Processor.WriteDirectly(entry, fileSettings, currentState.Settings);
else
Processor.Enqueue(entry, fileSettings, currentState.Settings);
}
}

Expand Down
36 changes: 36 additions & 0 deletions source/FileLogger/FileLoggerProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ public interface IFileLoggerProcessor : IDisposable

void Enqueue(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings);

/// <summary>
/// Writes a log entry directly to the file, bypassing the queue. Blocks the calling thread until the write completes.
/// </summary>
void WriteDirectly(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings);

Task ResetAsync(Action? onQueuesCompleted = null);
Task CompleteAsync();
}
Expand Down Expand Up @@ -363,6 +368,37 @@ public void Enqueue(in FileLogEntry entry, ILogFileSettings fileSettings, IFileL
Context.GetDiagnosticEventReporter()?.Invoke(new FileLoggerDiagnosticEvent.LogEntryDropped(this, logFile, entry));
}

public void WriteDirectly(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings)
{
LogFileInfo logFile;

lock (_logFiles)
{
if (_status == Status.Completed)
throw new ObjectDisposedException(nameof(FileLoggerProcessor));

if (_status != Status.Running)
return;

#if NET6_0_OR_GREATER
ref LogFileInfo? logFileRef = ref CollectionsMarshal.GetValueRefOrAddDefault(_logFiles, fileSettings, out bool logFileExists);
logFile = logFileExists ? logFileRef! : (logFileRef = CreateLogFile(fileSettings, settings));
#else
if (!_logFiles.TryGetValue(fileSettings, out logFile))
_logFiles.Add(fileSettings, logFile = CreateLogFile(fileSettings, settings));
#endif
}

// Synchronize with the background WriteFileAsync task to avoid concurrent access to LogFileInfo state.
lock (logFile)
{
WriteEntryAsync(logFile, entry, _forcedCompleteTokenSource.Token)
.AsTask()
.GetAwaiter()
.GetResult();
}
}

protected virtual string GetDate(string? inlineFormat, LogFileInfo logFile, in FileLogEntry entry)
{
return entry.Timestamp.ToLocalTime().ToString(inlineFormat ?? logFile.DateFormat ?? "yyyyMMdd", CultureInfo.InvariantCulture);
Expand Down
10 changes: 10 additions & 0 deletions source/FileLogger/LogFileSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ public interface ILogFileSettingsBase
bool? IncludeScopes { get; }
int? MaxQueueSize { get; }
LogFilePathPlaceholderResolver? PathPlaceholderResolver { get; }
bool? SynchronousWrite { get; }
}

public interface ILogFileSettings : ILogFileSettingsBase
Expand Down Expand Up @@ -60,6 +61,7 @@ protected LogFileSettingsBase(LogFileSettingsBase other)
IncludeScopes = other.IncludeScopes;
MaxQueueSize = other.MaxQueueSize;
PathPlaceholderResolver = other.PathPlaceholderResolver;
SynchronousWrite = other.SynchronousWrite;
}

public LogFileAccessMode? FileAccessMode { get; set; }
Expand Down Expand Up @@ -117,6 +119,8 @@ public string? TextBuilderType

public LogFilePathPlaceholderResolver? PathPlaceholderResolver { get; set; }

public bool? SynchronousWrite { get; set; }

#if NET8_0_OR_GREATER
public abstract class BindingWrapperBase<TOptions>
where TOptions : LogFileSettingsBase
Expand Down Expand Up @@ -177,6 +181,12 @@ public int? MaxQueueSize
get => Options.MaxQueueSize;
set => Options.MaxQueueSize = value;
}

public bool? SynchronousWrite
{
get => Options.SynchronousWrite;
set => Options.SynchronousWrite = value;
}
}
#endif
}
Expand Down
140 changes: 140 additions & 0 deletions test/FileLogger.Test/LoggingTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -595,4 +595,144 @@ public async Task Issue36_MinLevelShouldNotActAsAnInclusionList()
""
}, lines);
}

[Fact]
public async Task SynchronousWriteToMemoryWithoutDI()
{
const string logsDirName = "Logs";

var fileProvider = new MemoryFileProvider();

var filterOptions = new LoggerFilterOptions { MinLevel = LogLevel.Trace };

var options = new FileLoggerOptions
{
FileAppender = new MemoryFileAppender(fileProvider),
BasePath = logsDirName,
FileAccessMode = LogFileAccessMode.OpenTemporarily,
FileEncoding = Encoding.UTF8,
SynchronousWrite = true,
Files =
[
new LogFileOptions
{
Path = "sync.log",
MinLevel = new Dictionary<string, LogLevel>
{
[LogFileOptions.DefaultCategoryName] = LogLevel.Information,
}
},
],
};

var context = new TestFileLoggerContext(CancellationToken.None, completionTimeout: Timeout.InfiniteTimeSpan);
context.SetTimestamp(new DateTime(2017, 1, 1, 0, 0, 0, DateTimeKind.Utc));

bool diagnosticEventReceived = false;
context.DiagnosticEvent += _ => diagnosticEventReceived = true;

var provider = new FileLoggerProvider(context, Options.Create(options));

await using (provider)
using (var loggerFactory = new LoggerFactory([provider], filterOptions))
{
ILogger<LoggingTest> logger = loggerFactory.CreateLogger<LoggingTest>();

logger.LogInformation("Synchronous message 1.");
logger.LogWarning(1, "Synchronous message 2.");

// Because SynchronousWrite is true, the messages should already be written
// to the file at this point without needing to complete the provider.
var logFile = (MemoryFileInfo)fileProvider.GetFileInfo($"{logsDirName}/sync.log");
Assert.True(logFile.Exists && !logFile.IsDirectory);

string[] lines = logFile.ReadAllText(out Encoding encoding).Split([Environment.NewLine], StringSplitOptions.None);
Assert.Equal(Encoding.UTF8, encoding);
Assert.Equal(new[]
{
$"info: {typeof(LoggingTest)}[0] @ {context.GetTimestamp().ToLocalTime():o}",
$" Synchronous message 1.",
$"warn: {typeof(LoggingTest)}[1] @ {context.GetTimestamp().ToLocalTime():o}",
$" Synchronous message 2.",
""
}, lines);
}

Assert.False(diagnosticEventReceived);
}

[Fact]
public async Task SynchronousWritePerFileOverride()
{
const string logsDirName = "Logs";

var fileProvider = new MemoryFileProvider();

var filterOptions = new LoggerFilterOptions { MinLevel = LogLevel.Trace };

var options = new FileLoggerOptions
{
FileAppender = new MemoryFileAppender(fileProvider),
BasePath = logsDirName,
FileAccessMode = LogFileAccessMode.OpenTemporarily,
FileEncoding = Encoding.UTF8,
Files =
[
new LogFileOptions
{
Path = "sync.log",
SynchronousWrite = true,
MinLevel = new Dictionary<string, LogLevel>
{
[LogFileOptions.DefaultCategoryName] = LogLevel.Information,
}
},
new LogFileOptions
{
Path = "async.log",
MinLevel = new Dictionary<string, LogLevel>
{
[LogFileOptions.DefaultCategoryName] = LogLevel.Information,
}
},
],
};

var context = new TestFileLoggerContext(CancellationToken.None, completionTimeout: Timeout.InfiniteTimeSpan);
context.SetTimestamp(new DateTime(2017, 1, 1, 0, 0, 0, DateTimeKind.Utc));

var provider = new FileLoggerProvider(context, Options.Create(options));

await using (provider)
using (var loggerFactory = new LoggerFactory([provider], filterOptions))
{
ILogger<LoggingTest> logger = loggerFactory.CreateLogger<LoggingTest>();

logger.LogInformation("Test message.");

// The synchronous file should have the content immediately.
var syncLogFile = (MemoryFileInfo)fileProvider.GetFileInfo($"{logsDirName}/sync.log");
Assert.True(syncLogFile.Exists && !syncLogFile.IsDirectory);

string[] syncLines = syncLogFile.ReadAllText(out _).Split([Environment.NewLine], StringSplitOptions.None);
Assert.Equal(new[]
{
$"info: {typeof(LoggingTest)}[0] @ {context.GetTimestamp().ToLocalTime():o}",
$" Test message.",
""
}, syncLines);
}

// After provider disposal, the async file should also have the content.
var asyncLogFile = (MemoryFileInfo)fileProvider.GetFileInfo($"{logsDirName}/async.log");
Assert.True(asyncLogFile.Exists && !asyncLogFile.IsDirectory);

string[] asyncLines = asyncLogFile.ReadAllText(out _).Split([Environment.NewLine], StringSplitOptions.None);
Assert.Equal(new[]
{
$"info: {typeof(LoggingTest)}[0] @ {context.GetTimestamp().ToLocalTime():o}",
$" Test message.",
""
}, asyncLines);
}
}
2 changes: 2 additions & 0 deletions test/FileLogger.Test/SettingsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ public void ParsingOptions()
"{{nameof(FileLoggerOptions.TextBuilderType)}}": "{{typeof(CustomLogEntryTextBuilder).AssemblyQualifiedName}}",
"{{nameof(FileLoggerOptions.IncludeScopes)}}": true,
"{{nameof(FileLoggerOptions.MaxQueueSize)}}": 100,
"{{nameof(FileLoggerOptions.SynchronousWrite)}}": true,
}
}
""";
Expand Down Expand Up @@ -99,6 +100,7 @@ public void ParsingOptions()
Assert.Equal(typeof(CustomLogEntryTextBuilder), settings.TextBuilder.GetType());
Assert.True(settings.IncludeScopes);
Assert.Equal(100, settings.MaxQueueSize);
Assert.True(settings.SynchronousWrite);
}

[Fact]
Expand Down