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.