DI/HttpContext in custom ConsoleFormatter

75 views Asked by At

In my custom ConsoleFormatter I need to get a value from a service added to the DI. This value is saved in a SCOPED service because it generates a new value for every new request but should keep the value through the request.

The problem is that I don't know how to access a DI service in the ConsoleFormatter, what I've tried below is to put the services container into the options parameter. My thoughts here is that .NET Services is a singleton, and by this I can request my service at the logging time and get my scoped service.

public static class ConsoleLoggerExtensions
{
    public static ILoggingBuilder AddCustomConsoleFormatter(
    this ILoggingBuilder builder, Action<CustomConsoleFormatterOptions> options)
    {
        return builder.AddConsole(
            options => options.FormatterName = nameof(CustomLoggingFormatter))
            .AddConsoleFormatter<
                CustomLoggingFormatter, CustomConsoleFormatterOptions>(
                options);
    }
}

public sealed class CustomConsoleFormatterOptions : ConsoleFormatterOptions
{
    public IServiceProvider serviceProvider { get; set; }
}

public sealed class CustomLoggingFormatter : ConsoleFormatter, IDisposable
{
    static long RowIndex = 1;
    readonly IDisposable _optionsReloadToken;
    CustomConsoleFormatterOptions _formatterOptions;

    public CustomLoggingFormatter(
        IOptionsMonitor<CustomConsoleFormatterOptions> options)
        : base(nameof(SebLoggingFormatter))
    {
        (_optionsReloadToken, _formatterOptions) =
            (options.OnChange(ReloadLoggerOptions), options.CurrentValue);
    }

    public override void Write<TState>(in LogEntry<TState> logEntry,
        IExternalScopeProvider scopeProvider, TextWriter textWriter)
    {
        string message =
            logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception);
        if (message is null)
            return;

        var idService =
            _formatterOptions.serviceProvider.GetService<IIdService>();
        var requestId = idService.RequestId;
    }

    void ReloadLoggerOptions(CustomConsoleFormatterOptions options) =>
        _formatterOptions = options;

    public void Dispose() =>
        _optionsReloadToken?.Dispose();
}

And in my startup I add a Task to get the CustomFormnaytterOptions

services.AddLogging(opt => opt.AddCustomConsoleFormatter(options =>
{
    var serviceProvider = services.BuildServiceProvider();
    options.serviceProvider = serviceProvider;
}));

But when I request the IdService from the services I get back a service with an empty Value, and for me that might be coz:

  • No http-context (IdService is picking or creating the Id by the headers in the current request)
  • The .net Services I use here is something old from the time at startup (=not a singleton) and therefor not related to the current request

One thing I don't understand is this Option-coding-pattern that (ConsoleFormatterOptions uses) in .net, seems to be a built in feature. Should I in some way try to invalidate the options so it re-runs the task from startup?

1

There are 1 answers

1
Steven On BEST ANSWER

There are several problems with your solution that prevent it from working:

  • Setting the CustomConsoleFormatterOptions.serviceProvider field to services.BuildServiceProvider() will cause your formatter it to get a copy of all DI registrations. This won't give you access to the requests IIdService, and won't get you access to the active scope or HTTP request.
  • Calling BuildServiceProvider multiple times is a problematic practice, which is why there is a code analyzer in .NET Core that will warn you about this.
  • Loggers and their formatters are always assumed singletons by the framework. Although formatters can have dependencies injected into them, those dependencies are, should be, or will become singletons by themselves. If you inject the IServiceProvider, it will be the 'root' service provider, and won't give you access to scoped services.

The solution is to inject the IHttpContextAccessor class, as it allows accessing the current HttpContext:

public sealed class CustomLoggingFormatter : ConsoleFormatter
{
    private readonly IHttpContextAccessor accessor;

    public CustomLoggingFormatter(IHttpContextAccessor accessor)
        : base(nameof(SebLoggingFormatter))
    {
        this.accessor = accessor;
    }

    public override void Write<TState>(in LogEntry<TState> logEntry,
        IExternalScopeProvider scopeProvider, TextWriter textWriter)
    {
        string message =
            logEntry.Formatter?.Invoke(logEntry.State, logEntry.Exception);
        if (message is null)
            return;

        // Pull scoped service from request
        var idService =
            this.accessor.HttpContext.RequestServices.GetService<IIdService>();

        var requestId = idService.RequestId;
    }
}

This can be wired up as follows:

builder.AddHttpAccessor();

builder.AddConsole(
    options => options.FormatterName = nameof(CustomLoggingFormatter))
    .AddConsoleFormatter<
        CustomLoggingFormatter, CustomConsoleFormatterOptions>();