Serilog Logging in ASP.NET Core

ASP.NET Core comes with reasonable diagnostic logging; framework and application have access to API’s for structured logging. Log events are written out to handful providers including the terminal and Microsoft Application insights. One of the key sinks is the database logging that Microsoft API’s are lacking.

Serilog is an alternative logging implementation. It supports same structured logging API but adds a stock of features that makes it more appealing including logging to database.

We will need following packages from NuGet to configure Serilog for different sinks;

$ dotnet add package Serilog.ASPNetCore		<version 3.2.0>
$ dotnet add package Serilog.Settings.Configuration	<version 3.1.0>
$ dotnet add package Serilog.Sinks.Console		<version 3.1.1>
$ dotnet add package Serilog.Sinks.File			<version 3.2.0>
$ dotnet add package Serilog.Sinks.MSSqlServer		<version 5.1.3>

All types are in Serilog namespace.

using Serilog;

Initialization and top-level try/catch block

Exceptions thrown during application startup are some of the most challenging one. The very first line of code in a Serilog-enabled application will be in our program class main method;

Using Serilog

public static void Main(string[] args)
        {
            Log.Logger = new LoggerConfiguration()
                .Enrich.FromLogContext()
                .WriteTo.Console()      //this needs to be file system
                .CreateLogger();

            try
            {
                Log.Information("Starting up");
                CreateWebHostBuilder(args).Build().Run();
            }
            catch (Exception ex)
            {
                Log.Fatal(ex, "Application start-up failed");
            }
            finally
            {
                Log.CloseAndFlush();
            }
        }

Note that we have added .Enrich.FromLogContext() to the logger configuration. There are some features that requires this for example some of the properties like RequestId.

Plugging into ASP.NET Core

We have decided to have all log events processed through Serilog logging pipeline and we don’t want ASP.NET Core’s logging.

ASP.NET Core 2.1

public class Program
    {
        public static void Main(string[] args)
        {
            Log.Logger = new LoggerConfiguration()
                .Enrich.FromLogContext()
                .WriteTo.Console()      
                .CreateLogger();
            try
            {
                Log.Information("starting up");
                BuildWebHost(args).Run();
            }
            catch (Exception ex)
            {
                Log.Fatal(ex, "Application start-up failed");
            }
            finally
            {
                Log.CloseAndFlush();
            }
        }

        public static IWebHost BuildWebHost(string[] args) =>
            WebHost.CreateDefaultBuilder(args)
                .UseStartup<Startup>()
                .UseSerilog()
                .Build();
    }

ASP.NET Core > 3.1

      public class Program
    {
        public static void Main(string[] args)
        {
            Log.Logger = new LoggerConfiguration()
               .Enrich.FromLogContext()
               //.MinimumLevel.Debug()
               .WriteTo.Console()
               .CreateLogger();

            try
            {
                Log.Information("Application starting up");
                CreateHostBuilder(args).Build().Run();
            }
            catch (Exception ex)
            {
                Log.Fatal(ex, "Application start-up failed");
            }
            finally
            {
                Log.CloseAndFlush();
            }
        }

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

Cleaning up default logger

There are few spots in the application that tracks default logger. Serilog is a complete implementation of the .NET Core logging APIs. The benefit of removing default logger is that you are not running two different logging framework where they overlap in functionality.

The “Logging” section in appSettings.json is not used by Serilog so it can be removed;

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Warning",
      "Microsoft.Hosting.Lifetime": "Information"
    }
  },
  "AllowedHosts": "*"
}

After cleaning appSettings.Development.json, the configuration would be;

{
  "AllowedHosts": "*"
}

Writing your own log events

We have used Serilog Log class directly in Program.cs file to write event. This works well in ASP.NET Core apps and can be used with standard Serilog interfaces like Log, ILogger and LogContext.

Here is the simple log message that I can write in an action method;

Logger.LogInformation("Presentation Layer - Logging information message by calling index method");
Logger.LogWarning("Presentation Layer - Logging warning message by calling index method");
Logger.LogError("Presentation Layer - Logging error message by calling index method"); 

Navigate to your UI project and do this;

dotnet run

Serilog output will be;

Logging with File System

You will need following additional package from NuGet;

$ dotnet add package Serilog.Sinks.File			<version 3.2.0>

Add this line to Program.cs file Main method;

.WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day,
                   outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")

        public static void Main(string[] args)
        {
            Log.Logger = new LoggerConfiguration()
                .Enrich.FromLogContext()
                .WriteTo.File("Logs/log-.txt", rollingInterval: RollingInterval.Day,
                   outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
                .WriteTo.Console()      
                .CreateLogger();

A Logs folder and file with timestamp will be crated automatically. You will be able to see log in your project root directory;

Logging with SQL Server

You will need following additional package from NuGet;

$ dotnet add package Serilog.Settings.Configuration	<version 3.1.0>
$ dotnet add package Serilog.Sinks.MSSqlServer		<version 5.1.3>

We need to add following configuration in appSettings.json file for the Serilog;

//Logging configuration here
    "Serilog": {
        "ColumnOptions": {
            "addStandardColumns": [ "LogEvent" ],
            "removeStandardColumns": [ "MessageTemplate", "Properties" ],
            "timeStamp": {
                "columnName": "Timestamp",
                "convertToUtc":  false
            }
        },
        "ConnectionStrings": {
            "LogDatabase": "Data Source=MyDBServer;Initial Catalog=MyDb;Persist Security Info=True;Integrated Security=True"
        },
        "SchemaName": "dbo",
        "TableName": "MyLog"
    },

Refactor Program.cs file Main method;

public static void Main(string[] args)
        {
            var configuration = new ConfigurationBuilder()
                .SetBasePath(Directory.GetCurrentDirectory())
                .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
                .AddJsonFile($"appsettings.{Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? "Production"}.json", optional: true)
                .Build();

            Log.Logger = new LoggerConfiguration()
                .WriteTo.MSSqlServer(
                connectionString: configuration.GetSection("Serilog:ConnectionStrings:LogDatabase").Value,
                tableName: configuration.GetSection("Serilog:TableName").Value,
                appConfiguration: configuration,
                autoCreateSqlTable: true,
                columnOptionsSection: configuration.GetSection("Serilog:ColumnOptions"),
                schemaName: configuration.GetSection("Serilog:SchemaName").Value)
                .CreateLogger();

We will be deploying this in multiple environments and would like to standardized connection string. We are going to remove connection string from Serilog and going to add in ConnectionString section.

"ConnectionStrings": {
        "LogDatabase": "Data Source=myDBServer;Initial Catalog=myDB;Persist Security Info=True;Integrated Security=True",
    },

We are going to make this change in Program.cs main method;

            //var logConnectionString = configuration.GetSection("ConnectionStrings:LogDatabase").Value;
            //OR
            var logConnectionString = configuration.GetConnectionString("LogDatabase");

            Log.Logger = new LoggerConfiguration()
                .MinimumLevel.Debug()
                .WriteTo.MSSqlServer(
                    //connectionString: configuration.GetSection("Serilog:ConnectionStrings:LogDatabase").Value,
                    connectionString: logConnectionString,
                    tableName: configuration.GetSection("Serilog:TableName").Value,
                    //appConfiguration: configuration,
                    autoCreateSqlTable: true,
                    columnOptionsSection: configuration.GetSection("Serilog:ColumnOptions"),
                    schemaName: configuration.GetSection("Serilog:SchemaName").Value)
                .CreateLogger();

Let’s write simple message and view in Log table;

Logger.LogInformation("Presentation Layer - Logging information message by calling index method");
Logger.LogWarning("Presentation Layer - Logging warning message by calling index method");
Logger.LogError("Presentation Layer - Logging error message by calling index method");           

Here is the result;

Let’s throw a divide by zero exception;

var a = 1; var b = 0;
var x = a / b;

Here is the result;

Adding Serilog to DataAccess Layer

Add Serilog core library from NuGet (Refer above)

Add Database sink from NuGet (Refer above)

Add following code to your DataAccess layer methods for logging;

using System.Reflection;
using Serilog;

protected T ExecuteCode<T>(Func<T> code)
{
   try
   {
        return code.Invoke();
   }
   catch (SqlException ex)
   {
       string error = $"Unhandled Exception in {this.GetType().Name} class while executing {MethodBase.GetCurrentMethod().Name} method";
       Log.Error(ex, "DataAccess Layer: " + error);
       throw new ApplicationException(string.Format("{0}: {1}", ex.Number, ex.Message));
    }
}

Check your database for the logging info. It should be there.

Working with DI Injection

We can consume ILogger<T> interface from the framework with the help of dependency injection without configuring it in Program.cs class. Here is an example;

public class HomeController : Controller
    {
        private readonly ILogger<HomeController> logger;

        public HomeController(ILogger<HomeController> Logger)
        {
            Logger = logger;
        }

        public IActionResult Index([FromQuery] string name)
        {
            logger.LogInformation($"Hello, {name}!", name);
        }

All we need to do is to append “?name=world” and we will have the usual output.

Minimum Logging

These are Serilog logging levels;

Verbose

 Information is the noisiest level, rarely (if ever) enabled for a production app.

Debug

Debug is used for internal system events that are not necessarily observable from the outside, but useful when determining how something happened.

Information

Information events describe things happening in the system that correspond to its responsibilities and functions. Generally these are the observable actions the system can perform.

Warning

When service is degraded, endangered, or may be behaving outside of its expected parameters, Warning level events are used.

Error

When functionality is unavailable or expectations broken, an Error event is used.

Fatal

The most critical level, Fatal events demand immediate attention.

If we don’t want to log everything that framework generates, we exclude it by overriding;

using Serilog;
using Serilog.Events;

Log.Logger = new LoggerConfiguration()
                .MinimumLevel.Debug()
                .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)

The effect would be to generate events only at or above the warning level when the logger is owned by a type in a “Microsoft.*” namespace. We can specify as many overrides as we needed by adding additional “MinimumLevel.Override” statements;

Log.Logger = new LoggerConfiguration()
      .MinimumLevel.Debug()
      .MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
      .MinimumLevel.Override("System", LogEventLevel.Error)

This can also be configured in Json file as;

"Serilog": {
        "MinimumLevel": {
            "Default": "Debug",
            "Override": {
                "Microsoft": "Warning",
                "System":  "Error"
            }
        },

Calling Serilog.Settings.Configuration “JSON” support is a little bit misleading; it works with the Microsoft.Extensions.Configuration subsystem, so we could also control the minimum level through it by setting an environment variable called Serilog:MinimumLevel, or with overrides using Serilog:MinimumLevel:Default and Serilog:MinimumLevel:Override:Microsoft.

Following block could be the easiest way to read and override configuration;

var logConnectionString = configuration.GetConnectionString("LogDatabase");
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)      //read values from config file
       .WriteTo.MSSqlServer(
       	  connectionString: logConnectionString,
          tableName: configuration.GetSection("Serilog:TableName").Value,
          autoCreateSqlTable: true,   //will create table if table doesn't exists
          columnOptionsSection: configuration.GetSection("Serilog:ColumnOptions"),
       	  schemaName: configuration.GetSection("Serilog:SchemaName").Value)      
 .CreateLogger();

There is a logging switch support available. Some time later in the code, if we want minimum level to be information or something else, we can get that by;

using Serilog.Core;

var loggingLevelSwitch = new LoggingLevelSwitch();
loggingLevelSwitch.MinimumLevel = LogEventLevel.Information;

Resources

https://github.com/serilog-mssql/serilog-sinks-mssqlserver

Server permission for Serilog

https://github.com/serilog/serilog/wiki/Getting-Started

https://stackoverflow.com/questions/55245787/what-are-the-specifics-and-basics-of-connecting-serilog-to-a-mssql-database

https://stackoverflow.com/questions/64308665/populate-custom-columns-in-serilog-mssql-sink

https://benfoster.io/blog/serilog-best-practices/

https://nblumhardt.com/2016/07/serilog-2-minimumlevel-override/

https://nblumhardt.com/2014/10/dynamically-changing-the-serilog-level/

https://www.connectionstrings.com/store-and-read-connection-string-in-appsettings-json/

https://nblumhardt.com/2016/03/reading-logger-configuration-from-appsettings-json/

KeyValue pair class

The KeyValue pair class stores a pair of values in a single list.

It’s super easy to create a list of single value. Here is an example;

List<string> firstList =  new List<string> {"'cover page$'", "'i# milestones$'", "'ii# tasks$'" };
List<string> secondList = new List<string> { "'cover page$'", "'i# milestones$'", "'ii# tasks$'" };
var exceptList = secondList.Except(firstList);
Console.WriteLine($"\nsingle string: Value in second list that are not in first List");
foreach (var val in exceptList)
{
     Console.WriteLine($"single string: {val}");
}

What if we want to store pair of values instead of creating any custom classes? We can use KeyValue pair class;

var parentList = new List<KeyValuePair<string, string>>()
{
    new KeyValuePair<string, string>("v2-2021", "'cover page$'"),
    new KeyValuePair<string, string>("v2-2021", "'i# milestones$'"),
    new KeyValuePair<string, string>("v2-2021", "'ii# tasks$'"),
    new KeyValuePair<string, string>("v2-2021", "'iii# spendplan$'"),
};
var parentSubList = new List<KeyValuePair<string, string>>()
{
    new KeyValuePair<string, string>("v2-2021", "'cover page$'"),
    new KeyValuePair<string, string>("v2-2021", "'i# milestones$'"),
    new KeyValuePair<string, string>("v2-2021", "'ii# tasks$'"),
};
var exceptList1 = parentSubList.Except(parentList);
Console.WriteLine($"\nparentSubList->parentList: Value in second list that are not in first List");
foreach (var val in exceptList1)
{
    Console.WriteLine($"{val}");
}
IsASubset = parentSubList.All(i => parentList.Contains(i));
Console.WriteLine($"\nparentSubList->parentList: all members of subset (parentSubList) exists in list1 (parentList): {IsASubset}");
}

KeyValue pair class can also be used like this;

var myList = new List<KeyValuePair<string, string>>();
//add elements now
myList.Add(new KeyValuePair<string, string>("v2-2021", "'cover page$'"));
myList.Add(new KeyValuePair<string, string>("v2-2021", "'i# milestones$'"));
myList.Add(new KeyValuePair<string, string>("v2-2021", "'ii# tasks$'"));
foreach (var val in myList)
{
    Console.WriteLine($"Another style: {val}");
}

LINQ methods, for example Except can be used without implementing any Comparer classes.

Using LINQ methods to compare objects of custom type

I have a master list;

v2-2021 – ‘cover page$’
v2-2021 – ‘i# milestones$’
v2-2021 – ‘ii# tasks$’
v2-2021 – ‘iii# spendplan$’

I have a sub list;

v2-2021 – ‘cover page$’
v2-2021 – ‘i# milestones$’
v2-2021 – ‘ii# tasks$’

I want to make sure that all elements in my sub list exists in master list.

To solve this i have created this class;

internal class ExcelVersions
{
    public string VersionNumber { get; set; }
    public string TableName { get; set; }

}

I have created following objects based on this class;

List<ExcelVersions> cfirstList = new List<ExcelVersions>
{
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'cover page$'" },
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'i# milestones$'" },
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'ii# tasks$'" },
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'iii# spendplan$'" }
            };
            List<ExcelVersions> csecondList = new List<ExcelVersions>
            {
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'cover page$'" },
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'i# milestones$'" },
                new ExcelVersions { VersionNumber = "v2-2021", TableName = "'ii# tasks$'" }
            };
            //var cexceptList = csecondList.Except(cfirstList, new ExcelVersionsComparer());
            var cexceptList = csecondList.Except(cfirstList);
            Console.WriteLine($"\ncSecondList-->cFirstList: Value in second list that are not in first List");
            foreach (var val in cexceptList)
            {
                Console.WriteLine($"{val.TableName}");
            }
            IsASubset = csecondList.All(i => cfirstList.Contains(i));
            Console.WriteLine($"\ncSecondList-->cFirstList: all members of subset (cSecondList) exists in list1 (cFirstList): {IsASubset}");
}

This is the result i get;

To my surprise, none of LINQ comparison method worked on custom class. What’s wrong? The answer is in the LINQ implementation. To be correctly processed by the Except method, a type must implement the IEquatable<T> interface and provide its own Equals and GetHashCode methods.

Re-writing out custom type;

internal class ExcelVersions : IEquatable<ExcelVersions>
{
        public string VersionNumber { get; set; }
        public string TableName { get; set; }

        public bool Equals(ExcelVersions other)
        {
            //check whether the compare object is null
            if (Object.ReferenceEquals(other, null)) return false;
            //check whether the compared object references the same data
            if (Object.ReferenceEquals(this, other)) return true;
            //check whether the object's properteis are equal
            return VersionNumber.Equals(other.VersionNumber) && TableName.Equals(other.TableName);
        }

        //if Equals returns true for a pair of objects
        //GetHashCode must return the same value for these objects
        public override int GetHashCode()
        {
            //Get the hash code for the version number
            int hashVersionNumber = VersionNumber == null ? 0 : VersionNumber.GetHashCode();
            //get the hash code for the table name
            int hashTableName = TableName.GetHashCode();

            //calculate the hash code for the object
            return hashVersionNumber ^ hashTableName;
    }
}

This time the results are;

OK. Custom class is working but what if we cannot modify the type? What if it was provided by a library and we have no way of implementing the IEquiatable<T> interface. The answer is to create our own equality comparer and pass it as a parameter to the Except method.

The equality comparer must implement the IEqualityComparer<T> interface and provide GetHashCode and Equals method like this;

internal class ExcelVersionsComparer : IEqualityComparer<ExcelVersions>
{
        public bool Equals(ExcelVersions x, ExcelVersions y)
        {
            if (Object.ReferenceEquals(x, y))
                return true;

            if (Object.ReferenceEquals(x, null) || Object.ReferenceEquals(y, null))
                return false;
            return x.Equals(y);
        }

        public int GetHashCode(ExcelVersions excelVersion)
        {
            if (Object.ReferenceEquals(excelVersion, null)) return 0;

            int hashVersion = excelVersion.VersionNumber == null ? 0 : excelVersion.GetHashCode();
            int hashTable = excelVersion.TableName.GetHashCode();

            return hashVersion ^ hashTable;
        }
}

This is how we are going to pass the comparer to the Except method;

var cexceptList = csecondList.Except(cfirstList, new ExcelVersionsComparer());

These rules don’t just apply to Except method. For example, the same is true for the Distinct, Contains, Interset and Union methods. Generally, if you see that a LINQ method has an overload that accepts the IEqualityComparer<T> parameter, means that to use it with your own data type, you need to either implement IEquatable<T> in your class or create your own equality comparer.

If you want to use built-in class instead of creating custom class, consider this class;

Reference

https://docs.microsoft.com/en-us/archive/blogs/csharpfaq/how-to-use-linq-methods-to-compare-objects-of-custom-types

https://stackoverflow.com/questions/16824749/using-linq-except-not-working-as-i-thought

https://grantwinney.com/how-to-compare-two-objects-testing-for-equality-in-c/

https://www.tutorialspoint.com/how-to-find-items-in-one-list-that-are-not-in-another-list-in-chash


All Excel – Programmatically Reading/Writing

ERR: External table is not in the expected format;

The error might be the result of wrong extended properties in connection string. Check code examples below;

Following code will help you to open *.xls files;

string connString = string.Format("Provider = Microsoft.ACE.OLEDB.12.0; Data Source ={0}; Extended Properties = 'EXCEL 8.0 XML;HDR=YES;IMEX=1'", fullPathToExcel);
using (OleDbConnection conn = new OleDbConnection(connString))
{
   conn.Open();
  //...any other instruction
}

Following code will help you to open *.xlsx files;

string connString = string.Format("Provider = Microsoft.ACE.OLEDB.12.0; Data Source ={0}; Extended Properties = 'EXCEL 12.0 XML;HDR=YES;IMEX=1'", fullPathToExcel);
using (OleDbConnection conn = new OleDbConnection(connString))
{
   conn.Open();
  //...any other instruction
}

Excel ADO.NET properties for different excel version are;

Excel 97-2003 Workbook (.XLS) –> “Excel 80”

Excel 2007-2010 Workbook (.XLSX) –> “Excel 12.0 XML”

Excel 2007-2010 Macro-enabled Workbook (.XLSM) –> “Excel 12.0 MACRO”

This is how you can run a select query;

"SELECT * FROM [StoreSheet$A13:I]"

If you are not able to solve External table is not in the expected format error, consider changing driver. try NPOI or ExcelDataReader.

ExcelDataReader Library

This library doesn’t work with .NET core.

EPPlus Library

EPPlus library works with .NET core.

Working with Excel Files with the help of Script Task

Integration Services provides the Excel connection manager, Excel source, and Excel destination for working with data stored in spreadsheets in the Microsoft Excel file format. The techniques described in this topic use the Script task to obtain information about available Excel databases (workbook files) and tables (worksheets and named ranges).

You can read more here.

How to unprotect the Excel sheet

https://techcommunity.microsoft.com/t5/excel/how-to-unprotect-the-excel-sheet-if-forgot-the-password/m-p/1574559

Common variables and Namespaces used to read Excel files

Variables. Open the Variables window and define the following variables:
1- ExcelFile, of type String. Enter the complete path and filename to an existing Excel workbook.
2 -ExcelTable, of type String. Enter the name of an existing worksheet or named range in the workbook
named in the value of the ExcelFile variable. This value is case-sensitive.
3 – ExcelFileExists, of type Boolean.
4 – ExcelTableExists, of type Boolean.
5 – ExcelFolder, of type String. Enter the complete path of a folder that contains at least one Excel workbook.
6 – ExcelFiles, of type Object.
7 – ExcelTables, of type Object.

Imports statements
You will need following .NET Framework namespaces;
1 – System.IO, for file system operations.
2- System.Data.OleDb, to open Excel files as data sources.

References.
The code that read schema information from Excel files require an additional reference in the script project to the System.Xml namespace.

SSIS: Use Sql connection inside script component

If the requirement is to run dynamic query on each row, you might get inside Script comoponent. Here is how you can grab SQL connection and run your queries;

Your connection is defined here on script component;

Edit Script…
public class ScriptMain : UserComponent
{
    IDTSConnectionManager100 connMgr;
    SqlConnection sqlConn;
    SqlDataReader sqlReader;

    public override void AcquireConnections(object Transaction)
    {
        connMgr = this.Connections.MyConn;
        sqlConn = (SqlConnection)connMgr.AcquireConnection(null);
    }

    public override void ReleaseConnections()
    {
        //base.ReleaseConnections();
        connMgr.ReleaseConnection(sqlConn);
    }

    public override void PreExecute()
    {
        base.PreExecute();
        string KeyValue = Variables.vFileOutputString;

        String vSqlStatement = "SELECT SqlStatement FROM [DataHub].[vw_LastStepForRglData] WHERE ADNumber = '" + KeyValue + "' "; 
        SqlCommand cmd = new SqlCommand(vSqlStatement, sqlConn);
        sqlReader = cmd.ExecuteReader();
    }

    public override void PostExecute()
    {
        base.PostExecute();
        sqlReader.Close();
    }

    public override void CreateNewOutputRows()
    {
        while (sqlReader.Read())
        {
            {
                DynamicSqlBuffer.AddRow();
                DynamicSqlBuffer.SqlStatement = sqlReader.GetString(0);
            }
        }
    }
}

Hope this will help.