Custom Logging Provider in ASP.NET Core Web API

Custom Logging Provider in ASP.NET Core Web API

In this article, I will discuss How to Implement a Custom Logging Provider in ASP.NET Core Web API with an Example. Please read our previous article discussing Logging in ASP.NET Core Web API with Examples.

How Do We Implement Custom Logging Provider in ASP.NET Core?

ASP.NET Core’s logging framework is both flexible and extensible, allowing developers to easily integrate custom logging mechanisms according to their needs. By implementing ILogger and ILoggerProvider, we can store log messages in various destinations such as files, databases, or external services while taking advantage of built-in features like log levels, structured logging, and scope management.

Now, we will understand how to create a custom logging provider in the ASP.NET Core Web API application that writes logs to both a file system and a SQL Server database using Entity Framework Core. In this application, we will demonstrate how to:

  • Define custom logger and custom logger provider classes.
  • Configure log levels and filtering.
  • Use dependency injection to integrate the custom logging provider.
  • Store logs in two different backends: a file system and an SQL Server database.
Project Setup:

Create a new ASP.NET Core Web API project with the name CustomLoggerDemo and then install the following NuGet packages by executing the below command in Visual Studio Package Manager Console:

  • Install-Package Microsoft.EntityFrameworkCore.Tools
  • Install-Package Microsoft.EntityFrameworkCore.SqlServer

Note: The Microsoft.Extensions.Logging library is usually included by default in ASP.NET Core, so there is no need to install this package explicitly.

Define the Log Entity and EF Core DbContext

We need a simple entity to represent a log record and a corresponding EF Core context to store these records in SQL Server. First, create a folder named Models in the Project root directory, where we will create our Log Entity and DbContext classes.

Define the Log Entry Model

Create a class file named LogEntry.cs within the Models folder and then copy and paste the following code. This model represents a database log entry. The LogEntry class has properties for the message, log level, category, timestamp, exception, etc.

namespace CustomLoggerDemo.Models
{
    public class LogEntry
    {
        public int Id { get; set; }

        // The logger category name
        public string? Category { get; set; } 

        // The actual log message 
        public string? Message { get; set; }

        // The log level (e.g. Information, Error, etc.)
        public string LogLevel { get; set; }

        //Optional EventId
        public int? EventId { get; set; }

        // Exception details if any
        public string? Exception { get; set; }

        // The time when the log was created
        public DateTime CreatedTime { get; set; }
    }
}
Explanation of Each Property in the LogEntry Entity:

Each property in the LogEntry class serves a specific purpose in capturing and storing log data. Together, these properties ensure that log entries are well-structured, informative, and easy to filter, query, and analyze.

  • ID: This acts as the primary key for the database table and uniquely identifies each log entry record. It is important for querying, updating, or deleting specific logs in the database.
  • Category: Stores the category name under which the log was generated. In ASP.NET Core, loggers are often associated with categories, typically a fully qualified class name. This helps in filtering or grouping log messages from different parts of the application. For example, you might want to view only logs generated by a particular service or controller.
  • Message: Contains the main content or description of the log. It provides human-readable information explaining what happened at a specific time. This is often the core of a log entry.
  • LogLevel: This represents the severity of the log message (e.g., Trace, Debug, Information, Warning, Error, Critical). It allows us to filter logs by severity, enabling us to focus on more critical issues or reduce noise by excluding lower severity logs.
  • EventId: An optional identifier for the event being logged. It is useful in searching or grouping related events. For example, you could assign EventId = 1000 for user login, 2000 for checkout process, etc.
  • Exception: This holds the details of an exception if one was thrown during the operation that was logged. It helps in debugging by providing stack trace information and other exception details. This makes it easier to identify and fix bugs or understand why a particular operation failed.
  • CreatedTime: It records the exact date and time the log entry was created. It is useful for sorting, identifying when issues occurred, and maintaining an accurate history of application events.
Set Up the EF Core DbContext

Create a DbContext that will be used to store log entries in SQL Server. So, create a class file named LoggingDbContext.cs within the Models folder and then copy and paste the following code. LoggingDbContext inherits from DbContext and exposes a DbSet<LogEntry>, which EF Core uses to perform CRUD operations.

using Microsoft.EntityFrameworkCore;

namespace CustomLoggerDemo.Models
{
    public class LoggingDbContext : DbContext
    {
        public LoggingDbContext(DbContextOptions<LoggingDbContext> options)
       : base(options)
        {
        }

        // A DbSet to hold our log entries
        public DbSet<LogEntry> LogEntries { get; set; }
    }
}
Modifying AppSettings.json File:

Please modify the appsettings.json file as follows.

{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft": "Error",
      "Microsoft.AspNetCore": "Error",
      "System": "Error"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "LoggingDBConnection": "Server=LAPTOP-6P5NK25R\\SQLSERVER2022DEV;Database=LoggingDB;Trusted_Connection=True;TrustServerCertificate=True;"
  }
}
Implement the Custom Logger (ILogger)

Our custom logger will implement the ILogger interface. The ILogger interface is central to ASP.NET Core’s built-in logging system. It defines methods and properties that allow developers to log messages at different severity levels (such as Trace, Debug, Information, Warning, Error, and Critical). By implementing ILogger, custom loggers can integrate into the ASP.NET Core dependency injection and logging framework.

So, create a class file named CustomLogger.cs within the Models folder and copy and paste the following code. As EF Core’s DbContext is typically registered as a scoped service (which we cannot directly inject into a singleton logger), we will inject an IServiceScopeFactory into our logger.

using CustomLoggerDemo.Models; 
namespace CustomLoggerDemo.Logging
{
    // CustomLogger implements the ILogger interface, which provides a standard logging contract.
    public class CustomLogger : ILogger
    {
        // Private fields to hold configuration values for this logger instance.
        private readonly string _categoryName;         // Holds the logger category name (usually the class name where the logger is used).
        private readonly string _logFilePath;          // Path to the file where log messages will be written.
        private readonly LogLevel _minLogLevel;        // Minimum log level required to write a log (filters out less severe messages).
        private readonly IServiceScopeFactory _scopeFactory; // Used to create service scopes for dependency injection (for accessing EF Core DbContext).

        // Constructor to initialize the custom logger with required parameters.
        public CustomLogger(string categoryName, string logFilePath, LogLevel minLogLevel, IServiceScopeFactory scopeFactory)
        {
            _categoryName = categoryName;      // Assigns the category name for the logger.
            _logFilePath = logFilePath;        // Sets the file path where logs are written.
            _minLogLevel = minLogLevel;        // Sets the minimum log level; logs below this level will be ignored.
            _scopeFactory = scopeFactory;      // Assigns the service scope factory to allow resolving scoped services like the DbContext.
        }

        // BeginScope creates a logging scope. Not implemented here, so it returns null.
        // Scopes can be used to group a set of logical operations under a common context.
        public IDisposable BeginScope<TState>(TState state)
        {
            return null;
        }

        // IsEnabled checks if a given log level is enabled based on the minimum log level configured.
        // Only logs messages with a severity equal to or higher than _minLogLevel.
        public bool IsEnabled(LogLevel logLevel) 
        {
            return logLevel >= _minLogLevel;
        }

        // The Log method writes the log entry.
        // logLevel: The severity of the log (e.g., Information, Warning, Error).
        // eventId: An identifier for the event.
        // TState: This parameter can be any object that contains data to be logged.
        // exception: An optional exception object that provides error details if any, including the stack trace. 
        // formatter: A delegate that takes the state and exception as inputs and returns a formatted log message string.
        public void Log<TState>(LogLevel logLevel, EventId eventId, TState state, Exception exception, Func<TState, Exception, string> formatter)
        {
            // Check if the current log level is enabled. If not, exit early.
            if (!IsEnabled(logLevel))
                return;

            // Create the formatted message using the provided formatter function.
            var message = formatter(state, exception);

            // If the resulting message is null or empty, do not log.
            if (string.IsNullOrEmpty(message))
                return;

            // Build a log record string for the file with timestamp, log level, category, and message.
            var logRecord = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss} [{logLevel}] {_categoryName}: {message}";

            // Write the log message to the file system, appending a new line.
            File.AppendAllText(_logFilePath, logRecord + Environment.NewLine);

            // Write the log to SQL Server using EF Core.
            // Create a new service scope so that the DbContext is resolved in a scoped manner.
            using (var scope = _scopeFactory.CreateScope())
            {
                // Resolve the LoggingDbContext from the dependency injection container.
                var dbContext = scope.ServiceProvider.GetRequiredService<LoggingDbContext>();

                // Create a new LogEntry entity with the log details.
                var logEntry = new LogEntry
                {
                    Category = _categoryName,               // Store the logger category name.
                    Message = message,                      // Store the formatted log message.
                    EventId = eventId.Id,                   // Store the event ID (if provided).
                    LogLevel = logLevel.ToString(),         // Store the log level as a string.
                    Exception = exception?.ToString(),      // Store exception details if any exist.
                    CreatedTime = DateTime.Now              // Store the time when the log was created.
                };

                // Add the log entry to the database context.
                dbContext.LogEntries.Add(logEntry);

                // Save the changes to persist the log entry to the database.
                dbContext.SaveChanges();
            }
        }
    }
}
What is BeginScope?

BeginScope is a method defined in the ILogger interface that creates a new logging scope. A logging scope is essentially a contextual block of code during which certain logging information (like a Customer ID or Unique ID) is included automatically in every log entry. This can be extremely useful for grouping logs that are part of the same logical operation or request, especially in a distributed or multi-tenant environment.

For example, if you start a new scope with a specific user ID or transaction ID, every log within that scope can automatically include that information. This makes it easier to trace a series of events back to a single user or request when analyzing logs.

Creating a Custom Logger Provider in ASP.NET Core Web API

Create a new class file named CustomLoggerProvider.cs within the Models folder, and then copy and paste the following code:

namespace CustomLoggerDemo.Logging
{
    // CustomLoggerProvider implements the ILoggerProvider interface.
    // ILoggerProvider is part of the ASP.NET Core logging infrastructure,
    // and its main purpose is to create ILogger instances for different categories.
    public class CustomLoggerProvider : ILoggerProvider
    {
        // Private field to hold the file path where log messages will be written.
        private readonly string _logFilePath;

        // Private field that represents the minimum log level required to process a log message.
        private readonly LogLevel _minLogLevel;

        // Private field to store the IServiceScopeFactory.
        // This is used to create a new dependency injection (DI) scope,
        // allowing the logger to resolve scoped services (e.g., DbContext).
        private readonly IServiceScopeFactory _scopeFactory;

        // Constructor for the CustomLoggerProvider.
        // Parameters:
        //   logFilePath: The path to the log file where log messages will be appended.
        //   minimumLogLevel: The minimum log severity level that this provider will log.
        //   scopeFactory: A factory for creating DI scopes, used to resolve scoped services like LoggingDbContext.
        public CustomLoggerProvider(string logFilePath, LogLevel minimumLogLevel, IServiceScopeFactory scopeFactory)
        {
            _logFilePath = logFilePath;         // Assign the provided file path to the private field.
            _minLogLevel = minimumLogLevel;     // Assign the provided minimum log level to the private field.
            _scopeFactory = scopeFactory;       // Assign the provided scope factory to the private field.
        }

        // CreateLogger is called by the logging system to create an ILogger instance for a specific category.
        // Parameter:
        //   categoryName: A string that typically represents the name of the class or category where the logger is used.
        // Returns:
        //   A new instance of CustomLogger configured with the specified category, file path, log level, and scope factory.
        public ILogger CreateLogger(string categoryName)
        {
            // Create and return a new CustomLogger instance using the provided category name and the previously set fields.
            return new CustomLogger(categoryName, _logFilePath, _minLogLevel, _scopeFactory);
        }

        // Dispose method for cleaning up any resources if necessary.
        // In this simple example, there are no resources to dispose of, so the method is left empty.
        public void Dispose() { }
    }
}
Use of the ILoggerProvider Interface:

The ILoggerProvider interface defines a contract for creating logger instances. ILoggerProvider is responsible for creating and managing loggers in the ASP.NET Core logging system. Each logger is tied to a specific category (usually a namespace or class name) and is used to record log messages of various levels (Trace, Debug, Information, Warning, Error, Critical). The ILoggerProvider interface serves as a factory for these loggers.

When ASP.NET Core creates a logger for a given category (usually the fully qualified class name), it calls the CreateLogger method of each registered ILoggerProvider. The provider then returns an instance of ILogger (like our CustomLogger) that performs the actual logging operations.

Register the Custom Logger Provider in ASP.NET Core

In the Program.cs, we need to:

  • Register the EF Core LoggingDbContext.
  • Remove default providers (if desired).
  • Add a custom logger provider.

So, please modify the Program.cs class file as follows:

using CustomLoggerDemo.Logging;
using CustomLoggerDemo.Models;
using Microsoft.EntityFrameworkCore;

namespace CustomLoggerDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            // Add services to the container.
            builder.Services.AddControllers();
            builder.Services.AddEndpointsApiExplorer();
            builder.Services.AddSwaggerGen();

            // Configure EF Core for SQL Server logging.
            builder.Services.AddDbContext<LoggingDbContext>(options =>
                options.UseSqlServer(builder.Configuration.GetConnectionString("LoggingDBConnection")));

            // Now build the WebApplication.
            var app = builder.Build();

            // Configure the HTTP request pipeline.
            if (app.Environment.IsDevelopment())
            {
                app.UseSwagger();
                app.UseSwaggerUI();
            }

            app.UseHttpsRedirection();
            app.UseAuthorization();
            app.MapControllers();

            // --- Register the Custom Logger Provider ---
            // Define the file path where logs will be stored.
            string logFilePath = "Logs/logs.txt";

            // Retrieve the IServiceScopeFactory from the app's services.
            var scopeFactory = app.Services.GetRequiredService<IServiceScopeFactory>();

            // Add our custom logger provider to the logging factory.
            // You can choose the minimum log level you want (e.g., LogLevel.Information).
            var loggerFactory = app.Services.GetRequiredService<ILoggerFactory>();
            loggerFactory.AddProvider(new CustomLoggerProvider(logFilePath, LogLevel.Information, scopeFactory));

            app.Run();
        }
    }
}
Creating an API Controller to Test Different Log Level Messages:

Create an Empty API Controller named TestController within the Controllers folder and then copy and paste the following code.

using Microsoft.AspNetCore.Mvc;
namespace CustomLoggerDemo.Controllers
{
    [Route("api/[controller]")]
    [ApiController]
    public class TestController : ControllerBase
    {
        private readonly ILogger<TestController> _logger;

        public TestController(ILogger<TestController> logger)
        {
            _logger = logger;
        }

        [HttpGet("LogAllLevels")]
        public IActionResult LogAllLevels()
        {
            //Storing the Optional EventId
            var eventId = new EventId(1000);

            _logger.LogTrace("LogTrace: Entering the LogAllLevels endpoint with Trace-level logging.");

            // Simulate a variable and log it at Trace level
            int calculation = 5 * 10;
            _logger.LogTrace("LogTrace: Calculation value is {calculation}", calculation);

            _logger.LogDebug("LogDebug: Initializing debug-level logs for debugging purposes.");

            // Log some debug information
            var debugInfo = new { Action = "LogAllLevels", Status = "Debugging" };
            _logger.LogDebug("LogDebug: Debug information: {@debugInfo}", debugInfo);

            _logger.LogInformation(eventId, "LogInformation: The LogAllLevels endpoint was reached successfully.");

            // Simulate a condition that might cause an issue
            bool IsTakingMoreTime = true;
            if (IsTakingMoreTime)
            {
                _logger.LogWarning(eventId, "LogWarning: External API taking More Time to Respond. Action may be required soon.");
            }

            try
            {
                // Simulate an error scenario
                int x = 0;
                int result = 10 / x;
            }
            catch (Exception ex)
            {
                _logger.LogError(ex, "LogError: An error occurred while processing the request.");
            }

            // Log a critical error scenario
            bool criticalFailure = true;
            if (criticalFailure)
            {
                _logger.LogCritical("LogCritical: A critical system failure has been detected. Immediate attention is required.");
            }

            return Ok("All logging levels demonstrated in this endpoint.");
        }
    }
}
Generate and Apply Database Migration:

Now, open the Package Manager Console and execute the Add-Migration command to create a new Migration file. Then, execute the Update-Database command to apply the migration and update and sync the database with our model, as shown in the image below.

Creating a Custom Logger Provider in ASP.NET Core Web API

Once you execute the above commands, it should have created the Logging database with the Required LogEntries table, as shown in the below image:

Custom Logging Provider in ASP.NET Core Web API

Now, run the above application and access the Test/LogAllLevels endpoint. Once you access the endpoint and check the LogEntries table, you should see the following Log Messages.

How to Implement a Custom Logging Provider in ASP.NET Core Web API with an Example

Verifying the Text File:

Now, within the Logs folder, it should have created the logs.txt file, as shown in the image below.

How Do We Implement Custom Logging Provider in ASP.NET Core?

If you open the logs.txt file, you will also see the log messages.

Steps to Implement Custom Logger in ASP.NET Core Web API:

We have followed the steps below to create a custom logging provider for ASP.NET Core that writes logs to a file system and an SQL Server database using EF Core. The key steps included:

  • Defining the Log Model and DbContext: Creating a LogEntry entity and setting up the LoggingDbContext.
  • Implementing CustomLogger: Writing a custom implementation of ILogger that formats the log message and writes it to both the file system and SQL Server. We used IServiceScopeFactory to resolve a scoped LoggingDbContext instance safely.
  • Implementing CustomLoggerProvider: Creating a provider that returns instances of our custom logger.
  • Registering Everything in the ASP.NET Core Pipeline: Setting up EF Core, configuring the custom logger, and verifying that logs are written to both destinations.

In the next article, I will discuss How to Implement Logging using Serilog in ASP.NET Core Web API with Examples. In this article, I explain How to Implement a Custom Logging Provider in ASP.NET Core Web API with an Example. I hope you enjoy this article, Custom Logging Provider in ASP.NET Core Web API.

Leave a Reply

Your email address will not be published. Required fields are marked *