Blog Sending logs and traces

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 withPackageSetup call
SerilogZipLogger.Serilog.WriteTo.ZipLogger(endpoint, apiKey)
Microsoft.Extensions.Logging onlyZipLogger.Extensions.Loggingbuilder.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 Microsoft override 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 to ShutdownTimeout, 5 seconds by default.
  • Source is the service name you will filter on. If you leave it out, it defaults to the ZIPLOGGER_SOURCE environment 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.
  • SourceContext is renamed to category, the same name the ILogger provider uses.
  • {@Order} destructures an object into a nested field. It shows up as Order.Id, Order.Total and so on.
  • The exception goes into stackTrace, with its type and message as separate fields.
  • Levels map to debug (Verbose and Debug), info, warn, error and fatal.
  • If a trace is active, traceId and spanId are 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 +sha suffix. Override with Release or ZIPLOGGER_RELEASE.
  • commitSha: ZIPLOGGER_COMMIT_SHA, GIT_COMMIT or COMMIT_SHA, or the +sha suffix that SourceLink adds to the version.
  • environment: DOTNET_ENVIRONMENT or ASPNETCORE_ENVIRONMENT, otherwise Production.
  • 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 a 429 with Retry-After is 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.
  • Tags adds the same tags to every entry, for example options.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.

ASP.NET Core request metrics: p95 and throughput

Time every ASP.NET Core request with one NuGet package and see average latency, p95 and throughput per service. Real payloads, config, and limits.

Parse log lines into fields and metrics

Add fields to plain-text logs as they arrive with pattern, JSON or key=value rules, then turn matching logs into a metric you can chart and alert on.

Release health: crash-free sessions per release

See crash-free sessions, crash-free users and adoption for each release, from Sentry SDKs or ZipLogger's own SDKs, and get alerted when a release crashes more.

Try it on your own logs

There is a free plan and no sales call. Install an SDK, send a few log lines, and search them.