Integrating Serilog Into a .net Core Application

June 23, 2022
9 min read

Logging is obviously one of those things that is so basic, every app needs it.  Microsoft built it into the environment.  You are probably familiar with ILogger.  Well, there are many 3rd party plugins to ILogger and one of the more popular ones is Serilog.  With it, you can log to a variety of locations.  I like to have it output to my console when I'm developing so I can see the data that is moving around while I work.  Also, some of my web apps have a database of some of the logs so that I can pull them into the app and see them in my admin tools.

I recently had a project that needed logging sent into the DB, but I also wanted to catch the type of the log as well.  This type was a value I was adding via an enum into the Log.LogInformation() function.  In addition, I wanted to log hits to each API call.  Let's see how this was done.

I created a simple but thorough repo for this project so you can follow along.

APPSETTINGS

First off, I set the configuration in the appsettings.json:

"Serilog": {
    "Using": [ "Serilog.Sinks.Console", "Serilog.Sinks.MSSqlServer" ],
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft": "Error",
        "Microsoft.AspNetCore": "Warning"
      }
    },
    "Enrich": [ "FromLogContext" ],
    "WriteTo": [
      {
        "Name": "Console"
      },
      {
        "Name": "MSSqlServer",
        "Args": {
          "batchPostingLimit": 1,
          "period": "0.00:00:05",
          "connectionString": "DefaultConnection",
          "tableName": "EventLogs",
          "autoCreateSqlTable": false,
          "columnOptionsSection": {
            "addStandardColumns": [ "LogEvent" ],
            "removeStandardColumns": [ "MessageTemplate", "Properties" ],
            "additionalColumns": [
              {
                "ColumnName": "UserId",
                "DataType": "UniqueIdentifier",
                "AllowNull": true
              },
              {
                "ColumnName": "LogEventId",
                "DataType": "int"
              }
            ]
          }
        }
      }
    ]
  },

Copy

You'll see that the connectionString is actually using a named connection from the ConnectionStrings part of the appSettings.json.

Also, I have changed some of the columns around a bit to better suit my needs:

  1. Added the LogEvent column which hold a json string of the parameters getting logged.
  2. Removed MessageTemplate because it's just wasted space in my opinion.
  3. Removed Properties because it's similar to LogEvent, but in XML, which I dislike.
  4. Added a custom column called LogEventId, which is going to hold my enum value of the type of event.

DATA MODEL

I'm using Entity Framework Core for this example, so I need to create my model for this table.

public class EventLog
{
    [Key]
    [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
    public int Id { get; set; }
    public int? LogEventId { get; set; }        
    public string? Message { get; set; }
    public string? Level { get; set; }
    public DateTime TimeStamp { get; set; }
    public string? Exception { get; set; }
    // public string Properties { get; set; } // xml
    public string? LogEvent { get; set; } // json
}

Copy

Then add this model to your database by adding it to your DBContext:

public virtual DbSet<EventLog> EventLogs { get; set; }

Copy

And then using package manager to update:

PM>add-migration EventLogs
PM>update-database

Copy

PROGRAM.CS

In the Program class, we just need to add a reference to Serilog

public class Program
    {
        public static void Main(string[] args)
        {
            CreateHostBuilder(args).Build().Run();
        }


        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .UseSerilog()                
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });        
    }

Copy

Note the .UseSerilog(). That's the only thing of interest here.

STARTUP.CS

We have a few updates to handle in the Startup.cs.  The first is in the constructor, we just need to define Serilog:

// define serilog
Log.Logger = new LoggerConfiguration()
    .ReadFrom.Configuration(configuration)
    .Enrich.With<CommonEventEnricher>()
    .CreateLogger();

Copy

Since I'm using a configuration to hold my settings, that is the first thing getting loaded with the .ReadFrom.Configuration(configuration) line.  Using a config file makes it easy to make updates on the fly without having to recompile and redeploy.

Next is the Enrich.With<CommonEventEnricher>().  This is a custom implementation that holds logic to sniff out my custom enum value. I also added a reference to that in the ConfigureServices in order to register it:

services.AddTransient<CommonEventEnricher>();

Copy

Finally, in the Configure function, I added the ability to add other environmental properties, in this case, I added logging of the client's IP address, but you can be creative and add other things.

// capture ip address of client
app.UseSerilogRequestLogging(options =>
{
    options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
    {   
        diagnosticContext.Set("RemoteIpAddress", httpContext.Connection.RemoteIpAddress);          
    };
});

Copy

That's it for the Startup.cs.

CUSTOM ENRICHERS

In Serilog, it provides a way to “enrich” the properties.  It basically just means you can meddle with the data a bit before it's written out to the database.  I created a class that uses the ILogEventEnricher interface that Serilog provides.

public class CommonEventEnricher : ILogEventEnricher
    {
        public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
        {
            // check for a http request
            if(logEvent.MessageTemplate.Text.StartsWith("HTTP"))
            {
                logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("LogEventId", (ushort)LogEvents.HttpRequest));
                return;
            }

            // try to find the EventId, which is the first param passed in when using log.logInformation ...etc
            if (!logEvent.Properties.TryGetValue("EventId", out var propertyValue) ||
               !(propertyValue is StructureValue structureValue))
            {
                logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("LogEventId", (ushort)LogEvents.Unknown));
                return;
            }

            // find the Id sub-property
            for (var i = 0; i < structureValue.Properties.Count; ++i)
            {
                if (!"Id".Equals(structureValue.Properties[i].Name, StringComparison.Ordinal))
                {
                    continue;
                }

                if (!(structureValue.Properties[i].Value is ScalarValue scalarValue))
                {
                    continue;
                }

                var eventId = (ushort)(int)scalarValue.Value;
                logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty("LogEventId", eventId));
                return;
            }

            logEvent.AddOrUpdateProperty(propertyFactory.CreateProperty("LogEventId", (ushort)LogEvents.Unknown));
        }
    }

Copy

Also, it references my enum which is held in another file.  The enums are anything in the system you want to log:

public enum LogEvents
{
    Unknown = 0,
    HttpRequest = 1,
    SomeThing = 10,
    SomeThingElse = 11,
    WhoKnows = 12,

}

Copy

THE FINALE

Now that all of this is setup, you can call the standard ILogger functions and they will properly write out to the DB and Console.

// log some info in your controllers/services
log.LogInformation((int)LogEvents.Something, "User did something - {UserId}", uId);

Copy

Happy Logging!