Mert Özen Further With Every Line
Backend Logging in .NET — #04

Serilog Sinks: Console, File, Seq and OpenTelemetry — Async, Batching and Lost Logs

Mert Özen Nov 8, 2026 11 min 5 views
Serilog Sinks: Console, File, Seq and OpenTelemetry — Async, Batching and Lost Logs

In a container, logs go to stdout as compact JSON; if you need files, set rolling and retention explicitly. Serilog 4's core batching and the async wrapper either drop events or hold the app when a sink is slow: 101 of 2,000 events arrived, or the loop took 2.4 seconds. SelfLog, going out to OTLP and per-sink levels.

Version note: The code in this post was built and run with .NET 10, Serilog 4.4, Serilog.Sinks.File 7.0, Serilog.Sinks.Async 2.1, Serilog.Formatting.Compact 3.0, Serilog.Sinks.OpenTelemetry 4.2, Serilog.Expressions 5.0 and Seq 2026.1.

Guess First

There's a slow sink that spends 1 millisecond per event; imagine it writes to a remote HTTP (Hypertext Transfer Protocol) endpoint. So that it doesn't hold up the app, you wrap it in Serilog's async wrapper and limit the buffer to 100 events:

SlowSink slow = new();
Log.Logger = new LoggerConfiguration()
    .WriteTo.Async(a => a.Sink(slow), bufferSize: 100, blockWhenFull: block)
    .CreateLogger();
Stopwatch watch = Stopwatch.StartNew();
for (int i = 1; i <= 2_000; i++)
{
    Log.Information("Order {OrderId} exported", i);
}
watch.Stop();
await Log.CloseAndFlushAsync();

The loop writes 2,000 events. With blockWhenFull: false, how long does the loop take and how many events does the sink receive? With true? The program also calls CloseAndFlushAsync at the end, so "I forgot to flush" is no excuse. The answer is in the middle of the post.

stdout and Compact JSON in the Container World

In the previous post of the series we set up Serilog and wrote everything to the console with a human-readable template. For an app running in a container, the reader of the console is usually not a human but a log collector: Kubernetes or Docker captures stdout, and an agent reads it and carries it to the central system. The app doesn't deal with file paths, rotation or network connections; it writes the log to stdout and leaves the rest to the platform. The only requirement of this approach is that the line is in a format a machine can parse:

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console(new CompactJsonFormatter())
    .CreateLogger();
Log.Information("Order {OrderId} placed by customer {CustomerId}, total {OrderTotal}", 1001, 42, 91.0m);
Log.Error(new TimeoutException("Gateway did not answer in 30 s"), "Payment failed for order {OrderId}", 1002);
{"@t":"2026-11-08T02:21:55.3193220Z","@mt":"Order {OrderId} placed by customer {CustomerId}, total {OrderTotal}","OrderId":1001,"CustomerId":42,"OrderTotal":91.0}
{"@t":"2026-11-08T02:21:55.3399720Z","@mt":"Payment failed for order {OrderId}","@l":"Error","@x":"System.TimeoutException: Gateway did not answer in 30 s","OrderId":1002}

This is CLEF (Compact Log Event Format): @t is the UTC (Coordinated Universal Time) timestamp, @mt the message template, @x the exception. The first line has no @l, because Information is the default level in CLEF and isn't written; a few bytes per line, gigabytes over millions of lines a day. Properties sit at the top level, with no nested State object. If you have a target that wants the rendered message and doesn't understand message templates, RenderedCompactJsonFormatter renders the template into text and writes it as @m, together with a hashed event id. Seq reads CLEF directly; in the next post of the series we'll carry these lines into Seq.

The File Sink: Rolling, Retention and Shared Files

Not every app runs in a container. For a service on a Windows server or an app on a VM (Virtual Machine), a file is still the most robust target. The danger of the File sink is silently filling the disk:

Log.Logger = new LoggerConfiguration()
    .WriteTo.File("logs/bookstore-.log",
        rollingInterval: RollingInterval.Day,
        fileSizeLimitBytes: 2_000,
        rollOnFileSizeLimit: true,
        retainedFileCountLimit: 3)
    .CreateLogger();
for (int i = 1; i <= 150; i++)
{
    Log.Information("Stock updated for {Isbn}: {Quantity} left", "978-0135957059", i);
}

To show the limit, I used a ridiculously small value of 2,000 bytes. The logs folder after 150 events (size and name):

2059 bookstore-20261108_003.log
2000 bookstore-20261108_004.log
1680 bookstore-20261108_005.log

rollingInterval: RollingInterval.Day added the date to the file name, rollOnFileSizeLimit opened new files named _001, _002 when the limit was reached, and retainedFileCountLimit: 3 deleted the three oldest files; the first 78 events are no longer on disk. _003 going slightly over 2,000 bytes is documented behavior too: to avoid writing half an event, the sink writes the last event that crosses the limit in full. The first line of one of the remaining files:

2026-11-08 05:22:06.892 +03:00 [INF] Stock updated for 978-0135957059: 79 left

It's worth knowing the defaults, because not all of them are right for production. fileSizeLimitBytes defaults to 1 GB and retainedFileCountLimit to 31; if you don't think about the two together, up to 31 GB of logs can pile up with daily rolling. If rollOnFileSizeLimit isn't on, the sink doesn't open a new file when the limit is reached, it stops writing: when I wrote the same 150 events with this setting off, a single 2,045-byte file was left on disk and its last line was 26 left. shared: true lets several processes write to the same file (its default is false), but except for cases like two worker processes of an IIS (Internet Information Services) app, giving each process its own file means fewer surprises. buffered: true buffers disk writes and speeds them up; in return, if the process crashes, the last lines don't reach the disk.

Batching Moved into the Core with Serilog 4

Sinks that write over the network (Seq, Elasticsearch, OpenTelemetry) would be both slow and expensive if they sent a separate request for every event. Each sink used to rely on the Serilog.Sinks.PeriodicBatching package for that. With Serilog 4, batching moved into the core: the sink only knows how to send a ready batch, and Serilog manages the queue, the timing and the retries. A fake sink that sends logs to Bookstore's warehouse system:

// Serilog 4: the core does the batching, the sink only sends a ready batch
public sealed class WarehouseApiSink : IBatchedLogEventSink
{
    private int _received;

    public int Received => _received;

    public async Task EmitBatchAsync(IReadOnlyCollection<LogEvent> batch)
    {
        Console.WriteLine($"Sending batch of {batch.Count} events");
        await Task.Delay(200);
        Interlocked.Add(ref _received, batch.Count);
    }

    public Task OnEmptyBatchAsync() => Task.CompletedTask;
}
WarehouseApiSink warehouse = new();
Log.Logger = new LoggerConfiguration()
    .WriteTo.Sink(warehouse, new BatchingOptions
    {
        BatchSizeLimit = 100,
        BufferingTimeLimit = TimeSpan.FromSeconds(1),
        QueueLimit = 500
    })
    .CreateLogger();
for (int i = 1; i <= 2_000; i++)
{
    Log.Information("Order {OrderId} exported", i);
}
await Log.CloseAndFlushAsync();
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Sending batch of 100 events
Written: 2000, received by sink: 600

2,000 events were written, the sink received 600 of them. QueueLimit = 500 says at most 500 events are held in the queue; because the loop writes much faster than the sink, the queue filled up and the rest was discarded. Even with SelfLog on, not a single warning line appeared: a queue overflow is a silent loss. The defaults of BatchingOptions, according to the package's own documentation: BatchSizeLimit 1,000, BufferingTimeLimit 2 seconds, QueueLimit 100,000, RetryTimeLimit 10 minutes, and EagerlyEmitFirstEvent, which sends the first event without waiting, is on. The queue limit is a safety measure: an unbounded queue fills the app's memory with logs when the target goes down. Which one you pick is your answer to "lose logs, or run out of memory?".

The Async Wrapper: Speed or Loss?

Batching is for network sinks. But if you have a slow sink that works synchronously (an old database sink, a slow disk), Serilog.Sinks.Async moves it to a background thread: the log call drops the event into a buffer and returns immediately. The real answer to the opening question:

blockWhenFull=False: logging loop took 18 ms, sink received 101 of 2000
blockWhenFull=True: logging loop took 2407 ms, sink received 2000 of 2000

Both options give something up. With blockWhenFull: false (the default), the loop finished in 18 milliseconds, but 1,899 of the 2,000 events were dropped: the buffer filled at 100 and everything after that went into the bin. With blockWhenFull: true, no event was lost, but the loop took 2.4 seconds; the log call held the app's thread until there was room in the buffer. So the async wrapper doesn't make a slow sink faster, it only lets you choose where the cost lands: you either lose or you wait.

The default bufferSize is 10,000 (I read it from the package's method signature). Under normal traffic, that's enough to absorb momentary spikes. But if the sink is consistently slower than the incoming rate, no buffer is enough; the buffer only delays the start of the loss. Putting a sink that already batches inside the async wrapper is unnecessary too: the Seq or OpenTelemetry sink already works with its own queue.

SelfLog: The Sink's Own Errors

Sinks don't throw; if they did, writing a log would crash the app. That's why a Seq that's down, a wrong API (Application Programming Interface) key or a full disk leaves no trace by default. Serilog's own diagnostics channel is SelfLog. I pointed the Seq sink at a port nobody listens on:

SelfLog.Enable(Console.Error);
Log.Logger = new LoggerConfiguration()
    .WriteTo.Seq("http://localhost:17499")
    .CreateLogger();
Log.Information("Order {OrderId} placed", 1001);
await Log.CloseAndFlushAsync();
2026-11-08T02:22:10.5452980Z Serilog.Core.Sinks.Batching.BatchingSink: failed emitting a batch (Temporary, 1 events)
System.Net.Http.HttpRequestException: Connection refused (localhost:17499)
 ---> System.Net.Sockets.SocketException (61): Connection refused

The line carries two pieces of information. First, what wrote the error is Serilog.Core.Sinks.Batching.BatchingSink: the Seq sink uses the core batching from the previous section too. Second, "Temporary": Serilog considered this a transient failure and intended to retry, but because the process shut down right away with CloseAndFlushAsync, the event was lost without another attempt. In a long-lived web app, the same batch is retried until RetryTimeLimit runs out.

In production, send SelfLog not to the console but to stderr or a separate file, and collect it from there. Call SelfLog.Enable before building the logger; otherwise you miss errors during configuration (like the misspelled sink name from the previous post).

Going Out to OTLP with the OpenTelemetry Sink

OpenTelemetry defines a standard transport protocol for logs too: OTLP (OpenTelemetry Protocol). Serilog.Sinks.OpenTelemetry converts Serilog events into OTLP log records; the target can be an OpenTelemetry Collector or any backend that accepts OTLP. Seq itself accepts OTLP, so I did the test directly against Seq's /ingest/otlp endpoint:

Log.Logger = new LoggerConfiguration()
    .Enrich.WithProperty("Application", "Bookstore.Sinks")
    .WriteTo.OpenTelemetry(options =>
    {
        options.Endpoint = "http://localhost:17480/ingest/otlp";
        options.Protocol = OtlpProtocol.HttpProtobuf;
        options.ResourceAttributes = new Dictionary<string, object>
        {
            ["service.name"] = "bookstore-sinks",
            ["deployment.environment.name"] = "demo"
        };
    })
    .CreateLogger();
Log.Information("Order {OrderId} placed by customer {CustomerId}", 1001, 42);
await Log.CloseAndFlushAsync();

I didn't write /v1/logs in Endpoint; with the HTTP/protobuf protocol, the sink adds the standard /v1/logs path itself. Part of the event I fetched from Seq's API with the filter @Resource.service.name = 'bookstore-sinks':

"Properties": [
    {
        "Name": "Application",
        "Value": "Bookstore.Sinks"
    },
    {
        "Name": "CustomerId",
        "Value": 42
    },
    {
        "Name": "OrderId",
        "Value": 1001
    }
],
"Level": "INFO",
"Resource": [
    {
        "Name": "deployment",
        "Value": {
            "environment": {
                "name": "demo"
            }
        }
    },
    {
        "Name": "service",
        "Value": {
            "name": "bookstore-sinks"
        }
    },

The event's own properties and the resource attributes are kept separate. Information like service.name, "which service does this log come from", isn't sent as a property on every event but once, as a resource; in the OpenTelemetry world that's the key that tells services apart. One more thing: the sink's own identity was added to the resource too (telemetry.sdk.name with the value serilog), so in the backend you can see which library a log came from. The Collector side, meaning where and how logs are routed, is the topic of the upcoming DevOps › Observability series; the code side ends here.

Separating Levels Across Several Sinks

In real setups not every sink wants every event. Information and above to the console, only Error to the error file, only the payment service's logs to the payments team's file:

Log.Logger = new LoggerConfiguration()
    .MinimumLevel.Debug()
    .WriteTo.Console(restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Information)
    .WriteTo.File("logs/errors-.log", restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error,
        rollingInterval: RollingInterval.Day)
    .WriteTo.Logger(lc => lc
        .Filter.ByIncludingOnly("SourceContext like 'Bookstore.Payments%'")
        .WriteTo.File("logs/payments-.log", rollingInterval: RollingInterval.Day))
    .CreateLogger();
ILogger orders = Log.ForContext(Serilog.Core.Constants.SourceContextPropertyName, "Bookstore.Orders.OrderService");
ILogger payments = Log.ForContext(Serilog.Core.Constants.SourceContextPropertyName, "Bookstore.Payments.PaymentService");
orders.Debug("Cart {CartId} has {ItemCount} items", 7, 3);
orders.Information("Order {OrderId} placed", 1001);
payments.Information("Payment authorized for order {OrderId}", 1001);
payments.Error("Payment capture failed for order {OrderId}", 1002);

Console:

[05:22:29 INF] Order 1001 placed
[05:22:29 INF] Payment authorized for order 1001
[05:22:29 ERR] Payment capture failed for order 1002

errors-20261108.log and payments-20261108.log:

2026-11-08 05:22:29.289 +03:00 [ERR] Payment capture failed for order 1002

2026-11-08 05:22:29.282 +03:00 [INF] Payment authorized for order 1001
2026-11-08 05:22:29.289 +03:00 [ERR] Payment capture failed for order 1002

The Debug line went nowhere: the logger level is Debug, but each sink set its own lower bound. There's an important rule here: restrictedToMinimumLevel only narrows. If the logger's minimum level were Information, giving a sink Debug would open nothing; the event would never enter the pipeline. WriteTo.Logger is a sub-logger with its own filter and sinks. The filter expression comes from Serilog.Expressions; the same language, with like, @l, @x and friends, will show up again in this series' Seq post.

In Production

Microsoft's logging documentation is clear on this: ILogger has no async log methods, because logging is expected to be fast enough that async code isn't worth its cost. Don't write directly to a slow target, such as SQL (Structured Query Language) Server, from inside a Log method; the documentation's advice is to put the log into an in-memory queue first and have a background worker move it to the database asynchronously. In Serilog, the counterpart of that is a batching sink. A sink that runs a synchronous INSERT for every log line slows down the whole API the moment the database slows down; and when the database slows down is exactly when you need the logs most.

Keeping logs until the disk is full is an incident cause too. Always set rolling and retention explicitly, put the log folder on a different disk from the one the app runs on if you can, and add an alert on disk usage. A log's purpose is to describe the problem; it shouldn't be the problem.

Measure the loss. The batching queue and the async buffer drop events silently. Serilog.Sinks.Async can expose the buffer's fill level through a monitor parameter; in a critical system, connect that number to a metric. The simplest check is this: know how many events you should see in the log for a request, and verify it now and then.

And whatever the target, CloseAndFlushAsync is mandatory in short-lived processes. Remember that single line from the previous post that never reached Seq.

Common Pitfalls

First, reading the async wrapper as "logging is free now". The measurement showed it: when the buffer fills, you either lose events or hold the app's thread. The async wrapper is for short spikes; the fix for a consistently slow sink is replacing the sink.

Second, sending the same event to two network sinks (such as Seq and OTLP) and then being surprised by the bill. Two sinks mean twice the ingestion. If you have to use both during a migration, set a date.

Third, writing to both stdout and a file in a container. The file grows in the container's writable layer, disappears when the container restarts, and nobody reads it. In a container, stdout is enough.

Fourth, turning on SelfLog only in development. Sink errors happen in production: an expired API key, a changed endpoint, a network policy. Leaving SelfLog on and pointed at stderr costs almost nothing.

Fifth, trying to enable Debug with restrictedToMinimumLevel. A sink's level can't go below the logger's level; if you want Debug on for one sink, you have to lower the logger to Debug and narrow the other sinks.

A Small Experiment

★☆☆ Run the File sink example with retainedFileCountLimit: null and count how many files are created. Then set rollingInterval to RollingInterval.Hour and look at how the file names change.

★★☆ Change WarehouseApiSink's EmitBatchAsync to throw an exception on the first two calls, turn on SelfLog and lower RetryTimeLimit to 10 seconds. Watch which batches are retried and how many events finally reach the sink.

★★★ Attach an IAsyncLogEventSinkMonitor to the async wrapper example through the monitor parameter, and when the loop finishes print the buffer's fill level and the number of dropped events. Turn that number into a counter with System.Diagnostics.Metrics.

Our logs now go somewhere. In the next post we'll use one of those places, Seq, in depth: running it in Docker, ingestion with an API key, the query language, signals, dashboards and alerts.