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]