diff --git a/README.md b/README.md index d7fd1e9..e044851 100644 --- a/README.md +++ b/README.md @@ -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.
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 @@ -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") { diff --git a/source/FileLogger/FileLogger.cs b/source/FileLogger/FileLogger.cs index b9fee50..e56ddff 100644 --- a/source/FileLogger/FileLogger.cs +++ b/source/FileLogger/FileLogger.cs @@ -180,7 +180,11 @@ public void Log(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); } } diff --git a/source/FileLogger/FileLoggerProcessor.cs b/source/FileLogger/FileLoggerProcessor.cs index 94d68a1..6d1538f 100644 --- a/source/FileLogger/FileLoggerProcessor.cs +++ b/source/FileLogger/FileLoggerProcessor.cs @@ -21,6 +21,11 @@ public interface IFileLoggerProcessor : IDisposable void Enqueue(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings); + /// + /// Writes a log entry directly to the file, bypassing the queue. Blocks the calling thread until the write completes. + /// + void WriteDirectly(in FileLogEntry entry, ILogFileSettings fileSettings, IFileLoggerSettings settings); + Task ResetAsync(Action? onQueuesCompleted = null); Task CompleteAsync(); } @@ -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); diff --git a/source/FileLogger/LogFileSettings.cs b/source/FileLogger/LogFileSettings.cs index 089711f..64ef86a 100644 --- a/source/FileLogger/LogFileSettings.cs +++ b/source/FileLogger/LogFileSettings.cs @@ -30,6 +30,7 @@ public interface ILogFileSettingsBase bool? IncludeScopes { get; } int? MaxQueueSize { get; } LogFilePathPlaceholderResolver? PathPlaceholderResolver { get; } + bool? SynchronousWrite { get; } } public interface ILogFileSettings : ILogFileSettingsBase @@ -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; } @@ -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 where TOptions : LogFileSettingsBase @@ -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 } diff --git a/test/FileLogger.Test/LoggingTest.cs b/test/FileLogger.Test/LoggingTest.cs index e4be4e9..2846727 100644 --- a/test/FileLogger.Test/LoggingTest.cs +++ b/test/FileLogger.Test/LoggingTest.cs @@ -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 + { + [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 logger = loggerFactory.CreateLogger(); + + 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 + { + [LogFileOptions.DefaultCategoryName] = LogLevel.Information, + } + }, + new LogFileOptions + { + Path = "async.log", + MinLevel = new Dictionary + { + [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 logger = loggerFactory.CreateLogger(); + + 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); + } } diff --git a/test/FileLogger.Test/SettingsTest.cs b/test/FileLogger.Test/SettingsTest.cs index 3868467..71e6c4a 100644 --- a/test/FileLogger.Test/SettingsTest.cs +++ b/test/FileLogger.Test/SettingsTest.cs @@ -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, } } """; @@ -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]