Centralized .NET logging with Serilog and ILogger
Your ASP.NET Core app writes logs to the console or to a file on each server. When something breaks, you open a terminal on the right machine and grep. This post shows how to send those logs to one place with ZipLogger, using Serilog or plain ILogger, and how to make them searchable by user id or order id.
There are two NuGet packages. Use the one that matches the logging you already have:
| You log with | Package | Setup call |
|---|---|---|
| Serilog | ZipLogger.Serilog | .WriteTo.ZipLogger(endpoint, apiKey) |
Microsoft.Extensions.Logging only | ZipLogger.Extensions.Logging | builder.Logging.AddZipLogger() |
You only need one. Both target .NET 8 and .NET 10.
Both need an API key. Create one in the app under Settings → API keys with Create key. The full key starts with zk_ and is shown once, so copy it then. The package sends it in the X-Api-Key header for you.
Option 1: Serilog
Install the sink. The second package is Serilog's own ASP.NET Core integration, which routes ILogger<T> calls through Serilog.
dotnet add package ZipLogger.Serilog
dotnet add package Serilog.AspNetCore
Put the endpoint and key in appsettings.json:
{
"ZipLogger": {
"Endpoint": "https://app.ziplogger.ai",
"ApiKey": "YOUR_API_KEY"
}
}
In production, keep the key out of the file and set the environment variable ZipLogger__ApiKey instead. .NET configuration maps the double underscore to ZipLogger:ApiKey.
Then Program.cs:
using Serilog;
using Serilog.Events;
using ZipLogger.Serilog;
var builder = WebApplication.CreateBuilder(args);
var zl = builder.Configuration.GetSection("ZipLogger");
var config = new LoggerConfiguration()
.MinimumLevel.Information()
.MinimumLevel.Override("Microsoft", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console();
// The sink throws if the key is empty, so only add it when one is configured.
if (!string.IsNullOrWhiteSpace(zl["ApiKey"]))
{
config.WriteTo.ZipLogger(zl["Endpoint"]!, zl["ApiKey"]!,
options => options.Source = "orders-api");
}
Log.Logger = config.CreateLogger();
builder.Host.UseSerilog();
try
{
var app = builder.Build();
app.MapGet("/", () => "ok");
app.Run();
}
finally
{
Log.CloseAndFlush();
}
Three things in there are worth knowing:
- The
Microsoftoverride keeps the framework's per-request lines ("Request starting", "Executing endpoint") out. Without it they outnumber your own logs. - The sink buffers in memory.
Log.CloseAndFlush()disposes it, which sends what is left. It waits up toShutdownTimeout, 5 seconds by default. Sourceis the service name you will filter on. If you leave it out, it defaults to theZIPLOGGER_SOURCEenvironment variable, then the entry assembly name.
Log with properties, not string concatenation
The point of sending logs somewhere central is to find the ones that matter. That works when the values you care about are properties, not text baked into the message.
using Serilog;
using Serilog.Context;
using (LogContext.PushProperty("UserId", userId))
{
Log.Information("Order {OrderId} placed by {CustomerId}", order.Id, customerId);
try
{
ChargeCard(order);
}
catch (Exception ex)
{
Log.Error(ex, "Payment failed for order {OrderId}", order.Id);
}
}
This is what the sink sent for that error line in a local test, trimmed a little:
{
"source": "orders-api",
"severity": "error",
"message": "Payment failed for order 83112",
"release": "1.0.0",
"stackTrace": "System.InvalidOperationException: Card declined\r\n at ...",
"fields": {
"messageTemplate": "Payment failed for order {OrderId}",
"OrderId": 83112,
"UserId": "u_42",
"exceptionType": "System.InvalidOperationException",
"exceptionMessage": "Card declined",
"environment": "Production",
"machineName": "WEB-01"
}
}
How the sink maps things:
- Every Serilog property becomes a field. Numbers stay numbers, so you can filter on them.
SourceContextis renamed tocategory, the same name the ILogger provider uses.{@Order}destructures an object into a nested field. It shows up asOrder.Id,Order.Totaland so on.- The exception goes into
stackTrace, with its type and message as separate fields. - Levels map to
debug(Verbose and Debug),info,warn,errorandfatal. - If a trace is active,
traceIdandspanIdare added, and the log line links to that trace. See sending traces over OTLP.
One Serilog detail catches people out: string properties are rendered with quotes, so the info line above reads Order 83112 placed by "c_981". Write {CustomerId:l} if you want it without quotes. The field value is not affected either way.
Option 2: ILogger without Serilog
If your code only uses ILogger<T> and you do not want Serilog, add the provider instead.
dotnet add package ZipLogger.Extensions.Logging
It reads the Logging:ZipLogger section, and it supports the same per-category LogLevel rules as the console provider:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
},
"ZipLogger": {
"Endpoint": "https://app.ziplogger.ai",
"ApiKey": "YOUR_API_KEY",
"Source": "orders-api",
"LogLevel": { "Default": "Information" }
}
}
}
using ZipLogger.Extensions.Logging;
var builder = WebApplication.CreateBuilder(args);
builder.Logging.AddZipLogger();
The environment variable for the key is Logging__ZipLogger__ApiKey. You can also set everything in code with builder.Logging.AddZipLogger(options => { options.Endpoint = "..."; options.ApiKey = "..."; }). As with the sink, a missing endpoint or key throws an ArgumentException when the logger is created, so do not register it in environments that have no key.
Your logging code does not change:
public sealed class CheckoutService(ILogger<CheckoutService> logger)
{
public void Pay(Order order, string userId)
{
using (logger.BeginScope(new Dictionary<string, object?> { ["UserId"] = userId }))
{
logger.LogInformation("Order {OrderId} placed by {CustomerId}", order.Id, order.CustomerId);
try
{
ChargeCard(order);
}
catch (Exception ex)
{
logger.LogError(ex, "Payment failed for order {OrderId}", order.Id);
}
}
}
}
The fields come out the same way as with Serilog: OrderId and CustomerId from the template, UserId from the scope, messageTemplate, and category set to the logger's category (here the full name of CheckoutService). An EventId other than 0 is sent as eventId. Scopes that ASP.NET Core opens for each request, like RequestPath, are included too. You can turn scopes or the category off with IncludeScopes and IncludeCategory, both on by default.
Finding the logs in ZipLogger
Open Search. The query box searches the message unless you name a field. Your properties live under fields.:
fields.UserId:u_42
fields.OrderId:83112
severity:error AND source:orders-api
severity:error AND fields.Order.Id:83112
Field names are case-sensitive. fields.UserId and fields.userId are two different fields, so pick one style per property and keep it.
Click a line to expand it. You get the JSON fields, the built-in Log fields (severity, source, release, commitSha) and the Stack trace. Each value has ⊕ and ⊘ buttons to keep only matching lines or leave them out, which adds a filter chip. Investigate shows what is connected to the line: its trace, the release and any incident around it.
The Patterns view groups lines by what they are rather than listing them one by one. Because both .NET packages send messageTemplate, your lines are grouped exactly by the template you wrote, and the counts cover the whole time range. More on how that works in log pattern clustering.
Release, commit and environment
Each line is stamped automatically:
release: the entry assembly's informational version, minus any+shasuffix. Override withReleaseorZIPLOGGER_RELEASE.commitSha:ZIPLOGGER_COMMIT_SHA,GIT_COMMITorCOMMIT_SHA, or the+shasuffix that SourceLink adds to the version.environment:DOTNET_ENVIRONMENTorASPNETCORE_ENVIRONMENT, otherwiseProduction.machineName: the host name.
This is a field on each line. Which ZipLogger environment the line is stored in is decided by the API key, not by this value. See Separate staging and production logs.
The commit SHA plus the stack trace is what lets ZipLogger point at the commit that probably caused a new error. Setting ZIPLOGGER_COMMIT_SHA in your deploy is the simplest way to get it. See finding the commit that broke production.
What it does and does not do
- A logging call never blocks and never throws. Entries go into an in-memory queue of 10,000 (
QueueCapacity). When the queue is full, new entries are dropped rather than growing memory. - A background task sends batches of up to 100 entries (
BatchSize), waiting at most 2 seconds (FlushInterval) to fill one. - Failed requests are retried up to 5 times (
MaxRetries) with exponential backoff, and a429withRetry-Afteris respected. After that the batch is dropped. - There is no disk buffer. If the process is killed before a flush, what is still in memory is lost.
Tagsadds the same tags to every entry, for exampleoptions.Tags = ["checkout", "eu"];.
If you already export logs with OpenTelemetry, ZipLogger accepts OTLP over HTTP too, and you do not need either package.
The full option list is in the docs for Serilog and ILogger. To try it with your own app, create an account and make a key under Settings → API keys.