-
Notifications
You must be signed in to change notification settings - Fork 1
Initial File Logger Implementation. #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
2c4fdc2
d2945f9
2801fb1
1874b69
f711b66
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| namespace Nimble.Extensions.Logging.File; | ||
|
|
||
| /// <summary> | ||
| /// This determines the type of rolling for file logging. | ||
| /// </summary> | ||
| public enum FileLogRollingMethod | ||
| { | ||
| /// <summary> | ||
| /// No file rolling is performed. | ||
| /// </summary> | ||
| None = 0, | ||
|
|
||
| /// <summary> | ||
| /// File rolling is performed Daily. | ||
| /// </summary> | ||
| Daily = 1, | ||
|
|
||
| /// <summary> | ||
| /// File rolling is performed Weekly. | ||
| /// </summary> | ||
| Weekly = 2, | ||
|
|
||
| /// <summary> | ||
| /// File rolling is performed Monthly. | ||
| /// </summary> | ||
| Monthly = 3, | ||
|
|
||
| /// <summary> | ||
| /// File rolling is performed Yearly. | ||
| /// </summary> | ||
| Yearly = 4 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| namespace Nimble.Extensions.Logging.File; | ||
|
|
||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| internal class FileLogger : ILogger | ||
| { | ||
| private readonly string categoryName; | ||
| private readonly IOptions<FileLoggerOptions> _options; | ||
| private static readonly Lock _lock = new(); | ||
|
|
||
| public FileLogger(string categoryName, IOptions<FileLoggerOptions> options) | ||
| { | ||
| _options = options; | ||
| this.categoryName = categoryName; | ||
|
|
||
| // Create the log folder, this optionally includes the datestamped rolling | ||
| // log folders when rolling logging are enabled. | ||
| options.Value.GetFilter(this.categoryName)?.CheckFileRolling(); | ||
| } | ||
|
|
||
| internal IExternalScopeProvider? ScopeProvider { get; set; } | ||
|
|
||
| public bool IsEnabled(LogLevel logLevel) | ||
| { | ||
| var filter = _options.Value.GetFilter(categoryName); | ||
| return filter != null && logLevel >= filter.Value.MinLevel; | ||
| } | ||
|
|
||
| public void Log<TState>(LogLevel logLevel, EventId eventId, | ||
| TState state, Exception? exception, Func<TState, Exception?, string> formatter) | ||
| { | ||
| if (!IsEnabled(logLevel)) | ||
| { | ||
| return; | ||
| } | ||
|
|
||
| if (formatter != null) | ||
| { | ||
| if (_lock.TryEnter(Timeout.InfiniteTimeSpan)) | ||
| { | ||
| // We know for sure that if IsEnabled returns true, then filter is not null, | ||
| // but we need to get the filter again to get the file path. | ||
| var filter = _options.Value.GetFilter(categoryName); | ||
| var message = $"[{categoryName}] {logLevel}: {DateTime.UtcNow} {formatter(state, exception)}{Environment.NewLine}"; | ||
|
|
||
| // if file rolling is enabled this will update the file path in the filter to the new log file. | ||
| filter!.Value.CheckFileRolling(); | ||
| try | ||
| { | ||
| System.IO.File.AppendAllText(filter!.Value.FilePath, message); | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. While synchronous file writing is initially a good solution for thread safety, we will want to consider asynchronous methodology instead. File access is already blocking because of OS file handle logic. Asynchronous IO will deal with this handle more appropriately and ensure it gets access when it needs to, before handing back control. |
||
| } | ||
| finally | ||
| { | ||
| _lock.Exit(); | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| public IDisposable? BeginScope<TState>(TState state) where TState : notnull | ||
| => ScopeProvider?.Push(state); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| namespace Nimble.Extensions.Logging.File; | ||
|
|
||
| using System.Collections.Concurrent; | ||
| using System.Diagnostics; | ||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| /// <summary> | ||
| /// The options for the FileLogger. | ||
| /// </summary> | ||
| [DebuggerDisplay("{DebuggerToString(),nq}")] | ||
| public sealed class FileLoggerOptions | ||
| { | ||
| /// <summary> | ||
| /// Initializes a new instance of the <see cref="FileLoggerOptions" /> class. | ||
| /// </summary> | ||
| public FileLoggerOptions() | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a use for this constructor? If it is only for documenting purposes, I would personally not use it. |
||
| { | ||
| } | ||
|
|
||
| private ConcurrentDictionary<string, FileLoggerOptionsFilter> Filters { get; } = new(); | ||
|
|
||
| /// <summary> | ||
| /// Adds a new logging filter to this options instance. | ||
| /// </summary> | ||
| /// <param name="categoryName">The category for the filter.</param> | ||
| /// <param name="filePath">The file path (including file name) for the log file to output to.</param> | ||
| /// <param name="minLevel">The minimum level of log messages for the filter.</param> | ||
| /// <param name="captureScopes">To enable capturing of logging scopes in the filter.</param> | ||
| /// <returns>The same options so that multiple calls can be chained.</returns> | ||
| public FileLoggerOptions AddFilter( | ||
| string categoryName, | ||
| string filePath, | ||
| LogLevel minLevel = LogLevel.Debug, | ||
| bool captureScopes = true, | ||
| FileLogRollingMethod rollingMethod = FileLogRollingMethod.None) | ||
| { | ||
| Filters.TryAdd(categoryName, new FileLoggerOptionsFilter | ||
| { | ||
| CaptureScopes = captureScopes, | ||
| MinLevel = minLevel, | ||
| FilePath = filePath, | ||
| RollingMethod = rollingMethod, | ||
| }); | ||
| return this; | ||
| } | ||
|
|
||
| // TODO: Add the ability to configure the formatter in this Options instance for the file logger. | ||
| public FileLoggerOptions AddFormatter() | ||
| { | ||
| return this; | ||
| } | ||
|
|
||
| internal FileLoggerOptionsFilter? GetFilter(string categoryName) | ||
| { | ||
| foreach (var filter in Filters) | ||
| { | ||
| if (categoryName.StartsWith(filter.Key, StringComparison.Ordinal)) | ||
| { | ||
| return filter.Value; | ||
| } | ||
| } | ||
|
|
||
| // in the code that uses this method, if the return value from this is null, then it will be logged anyways. | ||
| return null; | ||
| } | ||
|
|
||
| internal string DebuggerToString() | ||
| => string.Join(", ", Filters.Values.Select(static flof => flof.DebuggerToString())); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| namespace Nimble.Extensions.Logging.File; | ||
|
|
||
| using Microsoft.Extensions.Logging; | ||
|
|
||
| internal struct FileLoggerOptionsFilter | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would like to have this have a base class that has the |
||
| { | ||
| public FileLoggerOptionsFilter() | ||
| { | ||
| } | ||
|
|
||
| public required bool CaptureScopes { get; set; } = true; | ||
|
|
||
| public required LogLevel MinLevel { get; set; } | ||
|
|
||
| public required string FilePath { get; set; } = string.Empty; | ||
|
|
||
| public required FileLogRollingMethod RollingMethod { get; set; } = FileLogRollingMethod.None; | ||
|
|
||
| private string? OriginalLogFolderName { get; set; } | ||
|
|
||
| private DateOnly? CurrentRollingDate { get; set; } | ||
|
|
||
| internal readonly string DebuggerToString() | ||
| => $"CaptureScopes = {CaptureScopes}, {( | ||
| MinLevel != LogLevel.None ? $"MinLevel = {MinLevel}" : "Enabled = false")}"; | ||
|
|
||
| internal void CheckFileRolling() | ||
| { | ||
| OriginalLogFolderName ??= Path.GetDirectoryName(FilePath) ?? string.Empty; | ||
| var directoryName = OriginalLogFolderName; | ||
| var fileName = Path.GetFileName(FilePath) ?? string.Empty; | ||
| if (RollingMethod is not FileLogRollingMethod.None) | ||
| { | ||
| var currentDate = DateOnly.FromDateTime(DateTime.UtcNow); | ||
|
|
||
| // if null or when the current date is greater than or equal to the current rolling date. | ||
| if (CurrentRollingDate is null || currentDate >= CurrentRollingDate) | ||
| { | ||
| CurrentRollingDate = RollingMethod switch | ||
| { | ||
| FileLogRollingMethod.Daily => currentDate.AddDays(1), | ||
| FileLogRollingMethod.Weekly => currentDate.AddDays(7), | ||
| FileLogRollingMethod.Monthly => currentDate.AddMonths(1), | ||
| FileLogRollingMethod.Yearly => currentDate.AddYears(1), | ||
| _ => throw new InvalidOperationException("bug by design.") | ||
| }; | ||
| } | ||
|
|
||
| // This should ideally compare the current date with a private stored date so that way there | ||
| // are no attempts to roll the file early in the case of the Monthly/Yearly file rolling | ||
| // options being used. | ||
| directoryName = RollingMethod switch | ||
| { | ||
| FileLogRollingMethod.Daily or FileLogRollingMethod.Weekly => Path.Combine(OriginalLogFolderName, currentDate.ToString("yyyy-MM-dd")), | ||
| FileLogRollingMethod.Monthly => Path.Combine(OriginalLogFolderName, currentDate.ToString("yyyy-MM")), | ||
| FileLogRollingMethod.Yearly => Path.Combine(OriginalLogFolderName, currentDate.ToString("yyyy")), | ||
| _ => throw new InvalidOperationException("bug by design.") | ||
| }; | ||
| } | ||
|
|
||
| CreateDirectory(directoryName); | ||
| FilePath = Path.Combine(directoryName, fileName); | ||
| } | ||
|
|
||
| private static void CreateDirectory(string namePath) | ||
| { | ||
| if (!string.IsNullOrEmpty(namePath)) | ||
| { | ||
| // If the directory containing the target file name to write to does not exist; create it. | ||
| try | ||
| { | ||
| Directory.CreateDirectory(namePath); | ||
| } | ||
| catch | ||
| { | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| namespace Nimble.Extensions.Logging.File; | ||
|
|
||
| using System.Collections.Concurrent; | ||
| using Microsoft.Extensions.Logging; | ||
| using Microsoft.Extensions.Options; | ||
|
|
||
| internal class FileLoggerProvider : ILoggerProvider, ISupportExternalScope | ||
| { | ||
| private readonly IOptions<FileLoggerOptions> _options; | ||
| private readonly ConcurrentDictionary<string, FileLogger> _loggers = new(); | ||
| private IExternalScopeProvider _scopeProvider = null!; | ||
|
|
||
| public FileLoggerProvider(IOptions<FileLoggerOptions> options) | ||
| => _options = options; | ||
|
|
||
| public ILogger CreateLogger(string name) | ||
| => _loggers.GetOrAdd(name, n => new FileLogger(n, _options)); | ||
|
|
||
| public void SetScopeProvider(IExternalScopeProvider scopeProvider) | ||
| { | ||
| _scopeProvider = scopeProvider; | ||
| foreach (var logger in _loggers) | ||
| { | ||
| logger.Value.ScopeProvider = _scopeProvider; | ||
| } | ||
| } | ||
|
|
||
| public void Dispose() | ||
| { | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This function strikes me as concerning. If the file's handle is acquired by another program, this function will infinitely hang the entire thread, and simultaneously all asynchronous logger writes too. All threads that do log writes within this scenario will wait to get access, causing potentially the entire process to hang. The only recovery is closing the file on whatever program is reading is so the handle can be handed back to this logger.