Change / override log event level in Serilog

It's possible, but not entirely straightforward, so strap yourself in!

1. Create a sink wrapper

Instead of an enricher, you'll need to create a wrapper around the target sink. The wrapper will receive events from the logging pipeline, (fairly cheaply) create new events with identical properties, and forward them to the actual sink:

class LevelBoostingWrapper : ILogEventSink, IDisposable
{
    readonly ILogEventSink _wrappedSink;

    public LevelBoostingWrapper(ILogEventSink wrappedSink)
    {
        _wrappedSink = wrappedSink;
    }

    public void Emit(LogEvent logEvent)
    {
        if (logEvent.Level == LogEventLevel.Warning)
        {
            var boosted = new LogEvent(
                logEvent.Timestamp,
                LogEventLevel.Error, // <- the boost
                logEvent.Exception,
                logEvent.MessageTemplate,
                logEvent.Properties
                    .Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)));

            _wrappedSink.Emit(boosted);
        }
        else
        {
            _wrappedSink.Emit(logEvent);
        }
    }

    public void Dispose()
    {
        (_wrappedSink as IDisposable)?.Dispose();
    }
}

The actual criterion for deciding which events to modify is up to you, of course.

2. Hook the wrapper into the configuration syntax

This little extension makes it more pleasant to set up the wrapper:

static class LoggerSinkConfigurationExtensions
{
    public static LoggerConfiguration Boosted(
        this LoggerSinkConfiguration lsc,
        Action<LoggerSinkConfiguration> writeTo)
    {
        return LoggerSinkConfiguration.Wrap(
            lsc,
            wrapped => new LevelBoostingWrapper(wrapped),
            writeTo);
    }
}

3. Add the wrapper to the configuration

Finally, in the logger configuration, apply the wrapper:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Boosted(wt => wt.Console())
    .CreateLogger();

Log.Information("This will be unchanged");
Log.Warning("This will be boosted to Error");
       
Log.CloseAndFlush();

This sink wrapping is great for default sinks. However I would like to configure serilog using a config file, but also wrap the configured sinks to modify specific calls to a lower loglevel.

This is the configuration of my sinks in appsetting.json

{
"Serilog": {
    "MinimumLevel": {
        "Default": "Verbose",
        "Override": {
            "Microsoft": "Warning",
            "System": "Warning"
        }
    },
    "WriteTo": [
        {
            "Name": "Console",
            "Args": {
                "outputTemplate": "===> {Timestamp:HH:mm:ss.fff} [{Level}] {Message}{NewLine}{Exception}"
            }
        },
        {
            "Name": "RollingFile",
            "Args": {
                "pathFormat": "c:\path\file.txt",
                "outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] - {Message}{NewLine}{Exception}"
            }
        },
        {
            "Name": "DurableHttpUsingTimeRolledBuffers",
            "Args": {
                "requestUri": "https://[elastic]",
                "bufferPathFormat": "c:\path\file.json"
            }
        }

Now what I do is create a logger from this config:

var configuration = new ConfigurationBuilder()
                  .SetBasePath(Directory.GetCurrentDirectory())
                  .AddJsonFile("appsettings.json", false, true)
                  .Build();
var configLoggerConfig = new LoggerConfiguration().ReadFrom.Configuration(configuration);
var configLogger = configLoggerConfig.CreateLogger();

And then try to wrap it:

var wrappedLoggerconfig = new LoggerConfiguration().WriteTo.LogLevelModifyingSink(wt => wt.Sink(configLogger), modifiers, levelSwitch);
        Log.Logger = wrappedLoggerconfig.CreateLogger();

The modifiers is a class that contains the logic for a specific event to be modified. The LogModifyingSink extentionmethod looks lik this:

public static LoggerConfiguration LogLevelModifyingSink(
         this LoggerSinkConfiguration loggerConfiguration,
         Action<LoggerSinkConfiguration> writeTo,
         ILogLevelModifiers logLevelModifiers,
         LoggingLevelSwitch levelSwitch)
    {
        return LoggerSinkConfiguration.Wrap(
            loggerConfiguration,
            wrapped => new LogLevelModifyingSink(wrapped, logLevelModifiers),
            writeTo,
            LogEventLevel.Verbose,
            levelSwitch); 
    }

In which the LogLevelModifyingSink is the wrappersink that emits the modified log:

public class LogLevelModifyingSink : ILogEventSink, IDisposable
{
    readonly ILogEventSink wrappedSink;
    readonly IEnumerable<LogLevelModifier> logLevelModifiers;

    public LogLevelModifyingSink(ILogEventSink wrappedSink, ILogLevelModifiers logLevelModifiers)
    {
        this.wrappedSink = wrappedSink;
        this.logLevelModifiers = logLevelModifiers?.GetLevelModifiers();
    }

    public void Dispose()
    {
        (wrappedSink as IDisposable)?.Dispose();
    }

    public void Emit(LogEvent logEvent)
    {
        var message = logEvent.RenderMessage();
        Console.WriteLine(DateTimeOffset.Now.ToString() + " " + message);

        if (wrappedSink != null && logLevelModifiers != null && logLevelModifiers.Any())
        {
            foreach(var modifier in logLevelModifiers)
            {
                if (modifier.ShouldModify(logEvent))
                {
                    wrappedSink.Emit(modifier.ModifyEvent(logEvent));
                    return;
                }
            }
        }
        wrappedSink.Emit(logEvent);
    }
}

Now this works partly. The logmessages are handled by the wrapper for all 3 sinks, however, by creating a logger from a new configuration, the settings for minumumlevel and minimumlevel overrides are not conveyed from the config file and there seams no way to get these settings at runtime. I feel like this solution is not the way to go since I create a logger twice. So my question is, is there a better way to wrap sinks from configuration? Is it possible at all? And how can I preserve my configured settings?


This solution is just a more concrete example based on Nicholas answer above.

In my .NET Core 3.1 application, I got the same requirement as Tamas to change the log level. I had also the requirement to create Audit logs.

More specifically, I want to keep the default http client logging defined in https://github.com/aspnet/HttpClientFactory/blob/master/src/Microsoft.Extensions.Http/Logging/LoggingHttpMessageHandler.cs but have it as DEBUG instead of INFO.

I tried the Serilog UseSerilogRequestLogging as defined in https://nblumhardt.com/2019/10/serilog-in-aspnetcore-3/ https://nblumhardt.com/2019/10/serilog-mvc-logging/ but without success.

I already created an Enricher which get the log level and create another property from it. I could extend this enricher to report a lower level as the real log level for httpClient, but it does not solve the logging, just display it differently.

The sink defined by Nicholas above is the correct approach.

The tricky part is to get the enricher AFTER the sink. So for that, we can create a sub logger as documented in the bottom part of https://github.com/serilog/serilog/wiki/Configuration-Basics

Some code to show a concrete example

public sealed class HttpClientLogLevelWrapper : ILogEventSink, IDisposable
{
    private const string SourceContext = "SourceContext";
    private const string HttpClientNamespace = "\"System.Net.Http.HttpClient";

    private readonly ILogEventSink _wrappedSink;
    private readonly LogEventLevel _logEventLevelTarget;

    /// <summary>
    /// Initializes a new instance of the <see cref="HttpClientLogLevelWrapper"/> class.
    /// </summary>
    /// <param name="wrappedSink">The wrapped sink.</param>
    /// <param name="logEventLevelTarget">The log event level target.</param>
    public HttpClientLogLevelWrapper(ILogEventSink wrappedSink, LogEventLevel logEventLevelTarget)
    {
        _wrappedSink = wrappedSink;
        _logEventLevelTarget = logEventLevelTarget;
    }

    public void Emit(LogEvent logEvent)
    {
        if (logEvent == null)
        {
            throw new ArgumentNullException(nameof(logEvent));
        }

        if (IsHttpClientInfoLog(logEvent))
        {
            var newLogEvent = new LogEvent(logEvent.Timestamp,
                _logEventLevelTarget,  // this is the only differnce with the original logEvent
                logEvent.Exception, logEvent.MessageTemplate,
                logEvent.Properties.Select(kvp => new LogEventProperty(kvp.Key, kvp.Value)));
            _wrappedSink.Emit(newLogEvent);
        }
        else
        {
            _wrappedSink.Emit(logEvent);
        }
    }

    private static bool IsHttpClientInfoLog(LogEvent logEvent)
    {
        if (logEvent.Properties.TryGetValue(SourceContext, out LogEventPropertyValue sourceContext))
        {
            string className = sourceContext.ToString();
            if (!string.IsNullOrEmpty(className)
                && className.StartsWith(HttpClientNamespace, StringComparison.OrdinalIgnoreCase)
                && logEvent.Level == LogEventLevel.Information)
            {
                return true;
            }
        }

        return false;
    }

    public void Dispose()
    {
        (_wrappedSink as IDisposable)?.Dispose();
    }
}

public static class LoggerSinkConfigurationExtensions
{
    public static LoggerConfiguration LowerHttpClientLoggingSink(this LoggerSinkConfiguration lsc, Action<LoggerSinkConfiguration> writeTo)
    {
        return LoggerSinkConfiguration.Wrap(lsc, wrapped => new HttpClientLogLevelWrapper(wrapped, LogEventLevel.Verbose), writeTo, LogEventLevel.Debug, null);
    }
}

and then the now quite complex configuration of the Logger in Program/Main

 // First read the wished minimum logger level, read from the enviromment variable.
 LogEventLevel minimumLoggerLevel = GetMinimumLogLevelFromEnvironmentVariable();

 // global shared logger, created BEFORE the host build to be able to log starting and ending the service.
 Log.Logger = new LoggerConfiguration()
     .MinimumLevel.Is(minimumLoggerLevel) 
     .MinimumLevel.Override("Microsoft", LogEventLevel.Error)
     .Enrich.FromLogContext()
     .Enrich.WithThreadId()
     .WriteTo.LowerHttpClientLoggingSink(wt => wt  // LowerHttpClientLogging update the log level from Info to Debug for HttpClient related logs.
        .Logger(lc => lc  // require a sub logger to have the Enrich AFTER the Sink!
        .Enrich.With(new LogLevelEnricher(LoggerAudit.AuditProperty, LogLevelUpperName)) // create levelUpper property and manage AuditProperty AFTER the sink!             
         .WriteTo
         .Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss,fff} [{ThreadId}] {" + LogLevelUpperName + "} - {Message:lj}{NewLine}{Exception}")))
         .CreateLogger();

The outputTemplate above is actually meant to match the pattern defined for log4j in other projects, since this pattern is then considered by filebeat for ElasticSearch/Kibana.

Tags:

C#

.Net

Serilog