skip to content
Jerrie Pelser's Blog

Implement user-facing logs with ASP.NET Core and Serilog

/ 9 min read

Introduction

User-facing logs is a fairly common concept in SaaS applications and other services that perform background jobs. For example, if you use a CI/CD tool such as GitHub actions, you can view a log of the deployment process to help you troubleshoot issues.

I wanted to implement something similar for Cloudpress to give the users the ability to view the actions performed during a job, and also allow them to troubleshoot issues.

The screenshot below show this feature in action. The log displays the various steps Cloudpress performed during an export. It also highlights any warnings or errors that occurred.

The Cloudpress job log

One important aspect of the logging is that I wanted the log entries to be written to both the user-facing job log and the normal application log (hosted on Azure Application Insights). This would allow me to also troubleshoot any issues on my end.

Deciding on the best approach

As described above, I want to write the job log entries to the user-facing job log as well as the normal application log. One approach you can achieve this is to write all log entries twice - once to the normal application log and once to the job log. It should be obvious that this is not an ideal approach.

A better approach would be to have the job logger wrap the application log and pass the job logger as parameter everywhere you want to write log entries related to a job. The job logger will handle the writing of the log entries to both logs.

As I was considering my options for implementing this, I came across a blog post by Nicholas Blumhardt (the creator of Serilog) about using sub-loggers with Serilog. This was exactly what I was looking for and seemed fairly simple to do. Since I was already using Serilog in Cloudpress, I decided to follow this approach.

Creating the job logger factory

Using the technique described above, let’s create a JobLoggerFactory that will handle the creation of an ILogger that will write to both the job and application log.

public class JobLoggerFactory(ILogger currentLogger, IHostEnvironment hostEnvironment)
{
public ILogger CreateLogger(Guid jobId)
{
return new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.WithProperty("JobId", jobId)
.WriteTo.File(GetLogFilePath(jobId),
LogEventLevel.Information,
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message}{NewLine}",
shared: true)
.WriteTo.Logger(currentLogger)
.CreateLogger();
}
private string GetLogFilePath(Guid jobId)
{
return Path.Combine(hostEnvironment.ContentRootPath, "logs", "jobs", $"{jobId:N}.txt");
}
}

The code above creates a logger with two sinks. The first is a file sink that will write the log entries for the job to a file on the file system. This file is located in the logs/jobs directory and uses the ID of the job as the name of the file.

The second sink writes to the existing application logger. Serilog will register the application-level logger with Dependency Injection, and we can access it inside the JobLoggerFactory by injecting it in the constructor. It is then a simple case of adding a sink for that logger by calling WriteTo.Logger(currentLogger) (see the highlighted line in the code snippet above).

We also need to add the JobLoggerFactory to the DI container and do that by registering it as a transient dependency during application startup.

var builder = WebApplication.CreateBuilder(args);
builder.Host.UseSerilog((context, serviceProvider, configuration) =>
{
//...
});
builder.Services.AddTransient<JobLoggerFactory>();
builder.Services.AddRazorPages();
var app = builder.Build();
//...
app.Run();

Using the job logger factory to write a job log

To use the job logger factory to create and write to a job log, we can inject it into whichever class we want to access it from and call the CreateLogger(..) method. We can then use the ILogger instance returned by this method to write the log entries.

You can see this in action in the demo project I created to accompany this blog post. In the demo project, I inject the JobLoggerFactory in the constructor of one of my Razor page models.

public class IndexModel(JobLoggerFactory jobLoggerFactory) : PageModel
{
//...
}

I then subsequently use the job logger factory to create a job log and write to it using the logging extension methods available on the ILogger interface.

var jobId = Guid.NewGuid();
var jobLogger = jobLoggerFactory.CreateLogger(jobId);
jobLogger.Information("...");

Each time I create a new job logger, a separate file is created for that particular job. You can see the individual log files in the screenshot below. In the case of my demo project, I also write the application log to the local file system, as seen in the screenshot.

The application log and the individual job log files

When I open a job log, you can see all the log entries for that particular job.

[2025-02-25 06:49:54.665 +07:00 INF] Start exporting document "Emma Brooks"
[2025-02-25 06:49:54.668 +07:00 INF] Exporting content to "Test Space: RichTextTest" ("CONTENTFUL")
[2025-02-25 06:49:54.668 +07:00 INF] Setting field values
[2025-02-25 06:49:54.669 +07:00 INF] Setting value for "title"
[2025-02-25 06:49:54.669 +07:00 INF] Setting value for "body"
[2025-02-25 06:49:54.670 +07:00 INF] Setting value for "byline"
[2025-02-25 06:49:54.670 +07:00 INF] Finished exporting document "Emma Brooks"

Opening the application log, you can see the same entries for that job log mixed in between all the normal application log entries.

...
2025-02-25 06:49:51.178 +07:00 [Information] Content root path: "C:\development\jerriepelser-blog\implement-user-facing-log-files"
2025-02-25 06:49:53.004 +07:00 [Information] Executed DbCommand ("2"ms) [Parameters=[""], CommandType='Text', CommandTimeout='30']"
""SELECT \"j\".\"Id\", \"j\".\"HasErrors\", \"j\".\"HasWarnings\", \"j\".\"QueuedAt\"
FROM \"Jobs\" AS \"j\"
ORDER BY \"j\".\"QueuedAt\" DESC"
2025-02-25 06:49:54.660 +07:00 [Information] Start executing the normal action
2025-02-25 06:49:54.665 +07:00 [Information] Start exporting document "Emma Brooks"
2025-02-25 06:49:54.668 +07:00 [Information] Exporting content to "Test Space: RichTextTest" ("CONTENTFUL")
2025-02-25 06:49:54.668 +07:00 [Information] Setting field values
2025-02-25 06:49:54.669 +07:00 [Information] Setting value for "title"
2025-02-25 06:49:54.669 +07:00 [Information] Setting value for "body"
2025-02-25 06:49:54.670 +07:00 [Information] Setting value for "byline"
2025-02-25 06:49:54.670 +07:00 [Information] Finished exporting document "Emma Brooks"
2025-02-25 06:49:54.814 +07:00 [Information] Executed DbCommand ("4"ms) [Parameters=["@p0='?' (DbType = Guid), @p1='?' (DbType = Boolean), @p2='?' (DbType = Boolean), @p3='?' (DbType = DateTime)"], CommandType='Text', CommandTimeout='30']"
""INSERT INTO \"Jobs\" (\"Id\", \"HasErrors\", \"HasWarnings\", \"QueuedAt\")
VALUES (@p0, @p1, @p2, @p3);"
2025-02-25 06:49:54.821 +07:00 [Information] Finished executing the normal action
...

Reading the log entries

Since the purpose of this blog post is to demonstrate how we can create user-facing logs, we also want the ability to get the contents of a job log. Let’s update the JobLoggerFactory to add a GetLogContents method that will read the contents of the job log file.

public class JobLoggerFactory(ILogger currentLogger, IHostEnvironment hostEnvironment)
{
//...
public async Task<string> GetLogContents(Guid jobId)
{
var path = GetLogFilePath(jobId);
if (!File.Exists(path))
{
return string.Empty;
}
using (var fileStream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var streamReader = new StreamReader(fileStream))
{
return await streamReader.ReadToEndAsync();
}
}
}

This method can then be used in the application to display the log entries to the user. You can see this in action in the demo project in the screenshot below.

Job log entries

Adding a warning or error indicator

In Cloudpress, I make it easy for the user to see when a job log contains warnings or errors by adding an indicator on the job status icon. Note the little yellow circle on the job status icon in the screenshot below that indicates the job log contains warnings.

The warning indicator on job entries

One way to do this is to parse the log file and see whether there are warnings or errors. However, this will be very slow as it will require me to read and parse the log files for all the jobs displayed in the list.

It would be more efficient to add an indicator on the Job table in the database and retrieve this along with all the other job details when querying my database.

To achieve this, I created a MonitoringLogger class that implements the ILogger interface. This class is a simple wrapper around an internal ILogger that intercepts the calls to the Write() method and set a flag when a warning or error was written to the log.

public class MonitoringLogger(ILogger internalLogger) : ILogger
{
public bool HasErrors { get; private set; }
public bool HasWarnings { get; private set; }
public void Write(LogEvent logEvent)
{
switch (logEvent.Level)
{
case LogEventLevel.Error:
HasErrors = true;
break;
case LogEventLevel.Warning:
HasWarnings = true;
break;
}
internalLogger.Write(logEvent);
}
}

I also update the JobLoggerFactory to return the MonitoringLogger instead of a normal ILogger.

public class JobLoggerFactory(ILogger currentLogger, IHostEnvironment hostEnvironment)
{
public MonitoringLogger CreateLogger(Guid jobId)
{
return new MonitoringLogger(new LoggerConfiguration()
.MinimumLevel.Debug()
.Enrich.WithProperty("JobId", jobId)
.WriteTo.File(GetLogFilePath(jobId),
LogEventLevel.Information,
"[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} {Level:u3}] {Message}{NewLine}",
shared: true)
.WriteTo.Logger(currentLogger)
.CreateLogger());
}

I can then update the job entries in the database to indicate whether the job contains warnings or errors.

var monitoringLogger = jobLoggerFactory.CreateLogger(jobId);
//... write log entries
var job = new Job
{
Id = jobId,
QueuedAt = DateTime.Now,
HasWarnings = monitoringLogger.HasWarnings,
HasErrors = monitoringLogger.HasErrors,
};
dbContext.Jobs.Add(job);
await dbContext.SaveChangesAsync();

In the demo application, I also added a small indicator to the job list to display when a job contains warnings or errors.

Job list with the warning and error indicator

A note on extensibility

In order to keep things simple for this blog post, I created a JobLoggerFactory that always writes job logs to the file system. In reality, you may want to write to different locations depending on the environment the application is running. In Cloudpress, for example, I write job logs to the file system during development. In production, I write the log files to Azure Blob Storage.

You can easily add this level of flexibility by creating an IJobLoggerFactory interface with different implementations.

public interface IJobLoggerFactory
{
public MonitoringLogger CreateLogger(Guid jobId);
}
public class FileJobLoggerFactory(ILogger currentLogger, IHostEnvironment hostEnvironment) : IJobLoggerFactory
{
public MonitoringLogger CreateLogger(Guid jobId)
{
// Configure the job log sink to write to the local file system
}
}
public class AzureBlobStorageJobLoggerFactory(ILogger currentLogger, IHostEnvironment hostEnvironment) : IJobLoggerFactory
{
public MonitoringLogger CreateLogger(Guid jobId)
{
// Configure the job log sink to write to Azure Blob Storage
}
}

Depending on the environment, you can then configure your DI container to inject the correct implementation, e.g.

if (hostingEnvironment.IsDevelopment())
{
services.AddTransient<IJobLoggerFactory, FileJobLoggerFactory>();
}
else
{
services.AddTransient<IJobLoggerFactory, AzureBlobStorageJobLoggerFactory>();
}

Conclusion

In this blog post, I demonstrated how you can add user-facing logs to your application. These user-facing logs can be useful to your application’s users to track down issues themselves. This functionality is built using Serilog’s ability to configure a sub-logger using sinks.

You can find the code for the accompanying demo application at https://github.com/jerriepelser-blog/implement-user-facing-log-files.