Skip to content
Draft
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
32 changes: 32 additions & 0 deletions src/Nimble.Extensions/Logging/File/FileLogRollingMethod.cs
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
}
63 changes: 63 additions & 0 deletions src/Nimble.Extensions/Logging/File/FileLogger.cs
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))

Copy link
Copy Markdown
Owner

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.

{
// 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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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);
}
69 changes: 69 additions & 0 deletions src/Nimble.Extensions/Logging/File/FileLoggerOptions.cs
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()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The 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()));
}
79 changes: 79 additions & 0 deletions src/Nimble.Extensions/Logging/File/FileLoggerOptionsFilter.cs
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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The 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 CaptureScopes and MinLevel properties where this would simply add FilePath on top of that just so it can be used to filter Console logs as well.

{
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
{
}
}
}
}
31 changes: 31 additions & 0 deletions src/Nimble.Extensions/Logging/File/FileLoggerProvider.cs
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()
{
}
}
60 changes: 45 additions & 15 deletions src/Nimble.Extensions/Logging/FormatterExtensions.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
{
Expand All @@ -42,20 +39,53 @@ public static ILoggingBuilder AddPrettierConsole(this ILoggingBuilder builder)
public static ILoggingBuilder AddPrettierConsole(this ILoggingBuilder builder, Action<PrettierFormatterOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);
builder.AddConsoleFormatter<PrettierFormatter, PrettierFormatterOptions>()
.AddConsole(static (options) => options.FormatterName = nameof(PrettierFormatter));
builder.Services.AddSingleton<WrittenLogTracker>()
.Configure(configure);
return builder;
}

/// <summary>
/// Adds a file logging provider to the <see cref="ILoggingBuilder" />.
/// </summary>
/// <param name="builder">The <see cref="ILoggingBuilder" /> to add the provider to.</param>
/// <param name="configureOptions">The action used to configure the logger.</param>
/// <param name="clearExistingProviders">If the existing providers added to the <see cref="ILoggingBuilder" /> should be removed.</param>
/// <returns>The <see cref="ILoggingBuilder" /> so that additional calls can be chained.</returns>
public static ILoggingBuilder AddFile(this ILoggingBuilder builder, Action<FileLoggerOptions> configureOptions, bool clearExistingProviders = false)
=> AddFileLoggerProvider(builder, configureOptions, clearExistingProviders);

/// <summary>
/// Adds a file logging provider to the <see cref="ILoggingBuilder" />.
/// </summary>
/// <param name="builder">The <see cref="ILoggingBuilder" /> to add the provider to.</param>
/// <param name="configureOptions">The action used to configure the logger.</param>
/// <param name="clearExistingProviders">If the existing providers added to the <see cref="ILoggingBuilder" /> should be removed.</param>
/// <returns>The <see cref="ILoggingBuilder" /> so that additional calls can be chained.</returns>
public static ILoggingBuilder AddFileLoggerProvider(this ILoggingBuilder builder, Action<FileLoggerOptions> configureOptions, bool clearExistingProviders = false)
{
if (clearExistingProviders)
{
builder.ClearProviders();
}

builder.AddConsoleFormatter<PrettierFormatter, PrettierFormatterOptions>();
builder.AddConsole((options) => options.FormatterName = nameof(PrettierFormatter));
builder.Services.AddSingleton<WrittenLogTracker>();
builder.Services.Configure(configure);
builder.Services
.Configure(configureOptions)
.AddSingleton<ILoggerProvider, FileLoggerProvider>();

// 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;
}

public static ILoggingBuilder AddConsoleListener(this ILoggingBuilder builder, Action<ConsoleListenerOptions> configure)
{
ArgumentNullException.ThrowIfNull(configure);

builder.Services.AddOptionsWithValidateOnStart<ConsoleListenerOptions>()
builder.Services
.AddOptionsWithValidateOnStart<ConsoleListenerOptions>()
.Configure(configure);
builder.Services.AddHostedService<ConsoleListener>();

Expand Down
6 changes: 3 additions & 3 deletions src/Nimble.Tests.Extensions/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -37,4 +37,4 @@ static void HandleCommand(string input, IServiceProvider srv, CancellationToken
}
else
logger.LogWarning("Unrecognized command: {Input}", input);
}
}