Skip to main content

Using FileLifecycleHooks in Serilog

In my post yesterday, I showed how to configure rolling and retention so a runaway log file no longer fills your disk. That solves the problem, but it leaves a question open: what if you want to do something with those files before they disappear?

Note: This post is a follow-up on Cleaning up old log files with Serilog.

Maybe you want to compress the files to save space. Maybe compliance requires you to archive them first. Serilog.Sinks.File has an extension point for this: FileLifecycleHooks.

What are FileLifecycleHooks?

A lifecycle hook lets you run your own code at specific moments in the life of a log file. You inherit from the abstract class FileLifecycleHooks and override one or both of these methods:

  • OnFileOpened: called when the sink opens a new log file. You receive the underlying stream and can return your own stream that wraps it.
  • OnFileDeleting: called just before a file is deleted by the retention policy.

Both have a default implementation that does nothing, so you only override what you need. You then pass your hook to the sink through the hooks parameter.

Remark: Newer versions of Serilog.Sinks.File also have an OnFileOpened overload that gives you the file path. The OnFileDeleting hook arrived together with retainedFileTimeLimit, so make sure you are on a recent version.

Example 1: compress log files

The most common use case is compression. Log files are text and compress very well, so this can reduce disk usage a lot.

csharp

using System.IO.Compression;
using System.Text;
using Serilog.Sinks.File;

public class GZipHooks : FileLifecycleHooks
{
    public override Stream OnFileOpened(Stream underlyingStream, Encoding encoding)
        => new GZipStream(underlyingStream, CompressionLevel.Fastest);
}

The hook wraps the stream Serilog writes to in a GZipStream. Everything that is written to the file is compressed on the fly. Using it looks like this:

Log.Logger = new LoggerConfiguration()
    .WriteTo.File(
        "logs/log-.gz",
        rollingInterval: RollingInterval.Day,
        fileSizeLimitBytes: 100 * 1024 * 1024,
        rollOnFileSizeLimit: true,
        retainedFileCountLimit: 14,
        hooks: new GZipHooks())
    .CreateLogger();

Note the .gz extension in the path. Without it you end up with compressed files that pretend to be plain text.

Remark: A gzip stream that is not closed properly is incomplete. If the process crashes, you can lose the last events of the current file. Also, don’t combine this with shared: true. Multiple processes writing into the same compressed stream is not going to end well.

Remark: As far as I know, the size limit is checked on the bytes that reach the file, so on the compressed size. A 100 MB limit then means 100 MB of gzip data, which is a lot more log events. Keep this in mind when you calculate your disk budget.

Example 2: archive files before they are deleted

Sometimes deleting a file after 14 days is fine locally, but you still need to keep the logs somewhere else for a longer period. OnFileDeleting is the place for this:

public class ArchiveBeforeDeleteHooks : FileLifecycleHooks
{
    private readonly string _archiveFolder;

    public ArchiveBeforeDeleteHooks(string archiveFolder)
    {
        _archiveFolder = archiveFolder;
        Directory.CreateDirectory(_archiveFolder);
    }

    public override void OnFileDeleting(string path)
    {
        var target = Path.Combine(_archiveFolder, Path.GetFileName(path));
        File.Copy(path, target, overwrite: true);
    }
}

In this example the file is copied to a network share. In real life this could also be an upload to Azure Blob Storage.

.WriteTo.File(
    "logs/log-.txt",
    rollingInterval: RollingInterval.Day,
    retainedFileCountLimit: 14,
    hooks: new ArchiveBeforeDeleteHooks(@"\\fileserver\archive\myapp"))

Remark: The hook runs synchronously, inside the logging pipeline, at the moment the sink rolls over to a new file. Keep it fast. A slow upload will block your logging. If something goes wrong, you won’t see an exception in your application. Enable SelfLog to find out what happened:

Serilog.Debugging.SelfLog.Enable(Console.Error);

Example 3: write a header at the top of each file

Another use case is a header at the start of every file. This is handy when the file is parsed by another tool, for example a CSV with column names:

public class HeaderHooks : FileLifecycleHooks
{
    private readonly string _header;

    public HeaderHooks(string header) => _header = header;

    public override Stream OnFileOpened(Stream underlyingStream, Encoding encoding)
    {
        if (underlyingStream.Length == 0)
        {
            var bytes = encoding.GetBytes(_header + Environment.NewLine);
            underlyingStream.Write(bytes, 0, bytes.Length);
        }
        return base.OnFileOpened(underlyingStream, encoding);
    }
}

The Length == 0 check is important. The same file can be opened again after a restart, and you don’t want a second header halfway through.

Don’t write it all yourself

For the examples above, there are community packages that already implement these hooks, for example Serilog.Sinks.File.Archive and Serilog.Sinks.File.Header. Check if one of them does what you need before you write your own.

When are hooks useful?

Hooks make sense when:

  • you keep log files on the server and disk space is limited
  • compliance or audit rules require you to keep logs longer than your local retention
  • another tool consumes the files and expects a specific format
  • you need to encrypt log files at rest

They are probably overkill when your logs already go to a central system like Seq, Elasticsearch or Application Insights. In that case the local file is just a fallback, and rolling with a retention limit is all you need.

Hooks form a simple extension point requiring only a small amount of code, and you stay in control of what happens to your log files.

More information

Popular posts from this blog

Podman– Command execution failed with exit code 125

After updating WSL on one of the developer machines, Podman failed to work. When we took a look through Podman Desktop, we noticed that Podman had stopped running and returned the following error message: Error: Command execution failed with exit code 125 Here are the steps we tried to fix the issue: We started by running podman info to get some extra details on what could be wrong: >podman info OS: windows/amd64 provider: wsl version: 5.3.1 Cannot connect to Podman. Please verify your connection to the Linux system using `podman system connection list`, or try `podman machine init` and `podman machine start` to manage a new Linux VM Error: unable to connect to Podman socket: failed to connect: dial tcp 127.0.0.1:2655: connectex: No connection could be made because the target machine actively refused it. That makes sense as the podman VM was not running. Let’s check the VM: >podman machine list NAME         ...

Cache stampede: when our cache turned against us

While investigating some performance issues, we ran into an ASP.NET Core API that cached a fairly expensive aggregation query for 60 seconds. Under normal load, that was fine: one request rebuilds the cache, everyone else reads from it. Under peak load, dozens of requests would arrive in that same expiry window, all see a cache miss, and all fire the same expensive query in parallel. The database didn't like that. That was the moment when our caching layer stopped helping and started hurting. A burst of requests comes in at the same time, all miss the cache, and all go hammer the database or the downstream API at once. That's a cache stampede . The cache was supposed to protect our backend, and for a few hundred milliseconds it did the opposite. Why this happens IMemoryCache.GetOrCreate (and its async sibling) looks like it protects you, but it doesn't add any locking on its own. Look at the naive version: public async Task<Report> GetReportAsync(string key) ...

VS Code Planning mode

After the introduction of Plan mode in Visual Studio , it now also found its way into VS Code. Planning mode, or as I like to call it 'Hannibal mode', extends GitHub Copilot's Agent Mode capabilities to handle larger, multi-step coding tasks with a structured approach. Instead of jumping straight into code generation, Planning mode creates a detailed execution plan. If you want more details, have a look at my previous post . Putting plan mode into action VS Code takes a different approach compared to Visual Studio when using plan mode. Instead of a configuration setting that you can activate but have limited control over, planning is available as a separate chat mode/agent: I like this approach better than how Visual Studio does it as you have explicit control when plan mode is activated. Instead of immediately diving into execution, the plan agent creates a plan and asks some follow up questions: You can further edit the plan by clicking on ‘Open in Editor’: ...