diff --git a/src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs b/src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs new file mode 100644 index 0000000..ccdbe69 --- /dev/null +++ b/src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs @@ -0,0 +1,32 @@ +namespace Nimble.Extensions.Logging.File; + +/// +/// This determines the type of rolling for file logging. +/// +public enum FileLogRollingMethod +{ + /// + /// No file rolling is performed. + /// + None = 0, + + /// + /// File rolling is performed Daily. + /// + Daily = 1, + + /// + /// File rolling is performed Weekly. + /// + Weekly = 2, + + /// + /// File rolling is performed Monthly. + /// + Monthly = 3, + + /// + /// File rolling is performed Yearly. + /// + Yearly = 4 +} diff --git a/src/Nimble.Extensions/Logging/File/FileLogger.cs b/src/Nimble.Extensions/Logging/File/FileLogger.cs new file mode 100644 index 0000000..18d2639 --- /dev/null +++ b/src/Nimble.Extensions/Logging/File/FileLogger.cs @@ -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 _options; + private static readonly Lock _lock = new(); + + public FileLogger(string categoryName, IOptions 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(LogLevel logLevel, EventId eventId, + TState state, Exception? exception, Func 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); + } + finally + { + _lock.Exit(); + } + } + } + } + + public IDisposable? BeginScope(TState state) where TState : notnull + => ScopeProvider?.Push(state); +} diff --git a/src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs b/src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs new file mode 100644 index 0000000..784e713 --- /dev/null +++ b/src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs @@ -0,0 +1,69 @@ +namespace Nimble.Extensions.Logging.File; + +using System.Collections.Concurrent; +using System.Diagnostics; +using Microsoft.Extensions.Logging; + +/// +/// The options for the FileLogger. +/// +[DebuggerDisplay("{DebuggerToString(),nq}")] +public sealed class FileLoggerOptions +{ + /// + /// Initializes a new instance of the class. + /// + public FileLoggerOptions() + { + } + + private ConcurrentDictionary Filters { get; } = new(); + + /// + /// Adds a new logging filter to this options instance. + /// + /// The category for the filter. + /// The file path (including file name) for the log file to output to. + /// The minimum level of log messages for the filter. + /// To enable capturing of logging scopes in the filter. + /// The same options so that multiple calls can be chained. + 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())); +} diff --git a/src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs b/src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs new file mode 100644 index 0000000..adfff47 --- /dev/null +++ b/src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs @@ -0,0 +1,79 @@ +namespace Nimble.Extensions.Logging.File; + +using Microsoft.Extensions.Logging; + +internal struct FileLoggerOptionsFilter +{ + 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 + { + } + } + } +} diff --git a/src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs b/src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs new file mode 100644 index 0000000..84902f8 --- /dev/null +++ b/src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs @@ -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 _options; + private readonly ConcurrentDictionary _loggers = new(); + private IExternalScopeProvider _scopeProvider = null!; + + public FileLoggerProvider(IOptions 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() + { + } +} diff --git a/src/Nimble.Extensions/Logging/FormatterExtensions.cs b/src/Nimble.Extensions/Logging/FormatterExtensions.cs index a51c0e2..9823ff8 100644 --- a/src/Nimble.Extensions/Logging/FormatterExtensions.cs +++ b/src/Nimble.Extensions/Logging/FormatterExtensions.cs @@ -1,6 +1,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Nimble.Extensions.Logging.Console; +using Nimble.Extensions.Logging.File; using System.ComponentModel; namespace Nimble.Extensions.Logging; @@ -12,16 +13,12 @@ namespace Nimble.Extensions.Logging; public static class FormatterExtensions { public static PrettierFormatterOptions ResetColors(this PrettierFormatterOptions options) - { - options.LogLevelColors[LogLevel.Trace] = ConsoleColor.Gray; - options.LogLevelColors[LogLevel.Debug] = ConsoleColor.Cyan; - options.LogLevelColors[LogLevel.Information] = ConsoleColor.Green; - options.LogLevelColors[LogLevel.Warning] = ConsoleColor.Yellow; - options.LogLevelColors[LogLevel.Error] = ConsoleColor.Red; - options.LogLevelColors[LogLevel.Critical] = ConsoleColor.Magenta; - - return options; - } + => options.SetColor(LogLevel.Trace, ConsoleColor.Gray) + .SetColor(LogLevel.Debug, ConsoleColor.Cyan) + .SetColor(LogLevel.Information, ConsoleColor.Green) + .SetColor(LogLevel.Warning, ConsoleColor.Yellow) + .SetColor(LogLevel.Error, ConsoleColor.Red) + .SetColor(LogLevel.Critical, ConsoleColor.Magenta); public static PrettierFormatterOptions SetColor(this PrettierFormatterOptions options, LogLevel level, ConsoleColor color) { @@ -42,12 +39,44 @@ public static ILoggingBuilder AddPrettierConsole(this ILoggingBuilder builder) public static ILoggingBuilder AddPrettierConsole(this ILoggingBuilder builder, Action configure) { ArgumentNullException.ThrowIfNull(configure); + builder.AddConsoleFormatter() + .AddConsole(static (options) => options.FormatterName = nameof(PrettierFormatter)); + builder.Services.AddSingleton() + .Configure(configure); + return builder; + } + + /// + /// Adds a file logging provider to the . + /// + /// The to add the provider to. + /// The action used to configure the logger. + /// If the existing providers added to the should be removed. + /// The so that additional calls can be chained. + public static ILoggingBuilder AddFile(this ILoggingBuilder builder, Action configureOptions, bool clearExistingProviders = false) + => AddFileLoggerProvider(builder, configureOptions, clearExistingProviders); + + /// + /// Adds a file logging provider to the . + /// + /// The to add the provider to. + /// The action used to configure the logger. + /// If the existing providers added to the should be removed. + /// The so that additional calls can be chained. + public static ILoggingBuilder AddFileLoggerProvider(this ILoggingBuilder builder, Action configureOptions, bool clearExistingProviders = false) + { + if (clearExistingProviders) + { + builder.ClearProviders(); + } - builder.AddConsoleFormatter(); - builder.AddConsole((options) => options.FormatterName = nameof(PrettierFormatter)); - builder.Services.AddSingleton(); - builder.Services.Configure(configure); + builder.Services + .Configure(configureOptions) + .AddSingleton(); + // here in my own original code I would register a "static logger" to use for general + // logs in my discord bot to possibly find bugs/issues in my code, but left it out here + // as not everyone happens to need said "logger". return builder; } @@ -55,7 +84,8 @@ public static ILoggingBuilder AddConsoleListener(this ILoggingBuilder builder, A { ArgumentNullException.ThrowIfNull(configure); - builder.Services.AddOptionsWithValidateOnStart() + builder.Services + .AddOptionsWithValidateOnStart() .Configure(configure); builder.Services.AddHostedService(); diff --git a/src/Nimble.Tests.Extensions/Program.cs b/src/Nimble.Tests.Extensions/Program.cs index 63e7a45..1f7e335 100644 --- a/src/Nimble.Tests.Extensions/Program.cs +++ b/src/Nimble.Tests.Extensions/Program.cs @@ -7,13 +7,13 @@ builder.Logging .SetMinimumLevel(LogLevel.Trace) - .AddPrettierConsole(configure => + .AddPrettierConsole(static configure => { configure.MaxLogWidth = 120; configure.TimestampColor = ConsoleColor.DarkCyan; configure.SpecialCategoryPrefix = "Nimble"; }) - .AddConsoleListener(configure => + .AddConsoleListener(static configure => { configure.CreateScopes = true; configure.OnReadlineCompleted = HandleCommand; @@ -37,4 +37,4 @@ static void HandleCommand(string input, IServiceProvider srv, CancellationToken } else logger.LogWarning("Unrecognized command: {Input}", input); -} \ No newline at end of file +}