Core Concepts
Serilog is a structured logging library for .NET. Instead of writing flat text lines, it captures each log event as an object with named properties. Seq is a log server designed specifically to store, index, and query those structured events. Together they turn logs from grep-able text files into a queryable database.
Structured logging vs. text logging
Classic loggers concatenate values into a single string. Once written, the values are lost inside that string. Structured logging keeps the values as first-class fields you can filter, sort, and aggregate on.
"Order 4211 for user alice placed at 12:03:11 total $84.20"
OrderId: 4211 User: "alice" Total: 84.20 Timestamp: 2026-07-31T12:03:11Z
Total > 50. Aggregate: sum(Total) by User.Message templates
Serilog uses message templates, not format strings. Placeholders are named, and Serilog stores each substituted value as a property on the event.
// String.Format style — DON'T DO THIS
_logger.LogInformation($"Order {orderId} placed by {user}");
// Message template — YES
_logger.LogInformation("Order {OrderId} placed by {User}", orderId, user);
The first form flattens everything to text before Serilog sees it — you lose OrderId and User as properties. The second form keeps them structured. This is the single most important rule in Serilog.
Sinks
A sink is a destination for log events. Serilog has one core library and dozens of sink packages: Console, File, Seq, Elasticsearch, Application Insights, PostgreSQL, and so on. You can attach multiple sinks and each one can have its own minimum level and filter.
Serilog vs. Microsoft.Extensions.Logging
ASP.NET Core exposes ILogger<T> through the built-in Microsoft.Extensions.Logging abstraction. Serilog plugs in underneath that abstraction: your code keeps injecting ILogger<T>, and Serilog becomes the provider that actually writes the events. You get structured logging without changing every log call site.
ILogger<T> calls flow through it to every configured sink.What Seq is
Seq is a self-hosted log server. It ingests JSON-formatted events, indexes their properties, and gives you a query language that reads like SQL over structured data. It runs as a single Windows service, a Docker container, or a Linux daemon. Free for individuals; paid for teams.
| Feature | What it gives you |
|---|---|
| Ingestion API | HTTP endpoint that accepts batches of JSON events from any Serilog Seq sink or plain HTTP. |
| Property indexing | Every property in every event is queryable. No schema up front. |
| Query language | SQL-like syntax: select count(*) from stream where Level = 'Error' and Application = 'OrderService'. |
| Signals | Saved filters. "Errors in OrderService today" becomes a one-click view. |
| Dashboards | Charts driven by queries. |
| Alerts | Trigger Slack/email/webhook when a query matches. |
| API keys | Per-application tokens that identify the source and can force properties on events. Covered in depth in section 6. |
Setup
NuGet packages
# Core Serilog + ASP.NET Core integration dotnet add package Serilog.AspNetCore # Read logger config from appsettings.json dotnet add package Serilog.Settings.Configuration # Sinks dotnet add package Serilog.Sinks.Console dotnet add package Serilog.Sinks.Seq dotnet add package Serilog.Sinks.File # Common enrichers dotnet add package Serilog.Enrichers.Environment dotnet add package Serilog.Enrichers.Process dotnet add package Serilog.Enrichers.Thread
Serilog.AspNetCore is the meta package. It brings Serilog, Serilog.Extensions.Hosting, and the middleware that hooks Serilog into the generic host and request pipeline.
Minimal Program.cs
using Serilog;
// Bootstrap logger — used during startup, before the host is built.
// Catches any failure in Startup itself.
Log.Logger = new LoggerConfiguration()
.WriteTo.Console()
.CreateBootstrapLogger();
try
{
var builder = WebApplication.CreateBuilder(args);
// Replace the default MEL provider with Serilog, and read the real
// configuration from appsettings.json.
builder.Host.UseSerilog((context, services, config) => config
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext());
var app = builder.Build();
// Adds request logging: one structured event per HTTP request with
// method, path, status code, and elapsed ms.
app.UseSerilogRequestLogging();
app.MapGet("/", () => "Hello");
app.Run();
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
}
finally
{
Log.CloseAndFlush();
}
Log.CloseAndFlush(). Serilog batches events for performance. If your process exits without flushing, the last few seconds of logs — often the ones you need most — are lost.appsettings.json (source of truth)
{
"Serilog": {
"Using": [
"Serilog.Sinks.Console",
"Serilog.Sinks.Seq",
"Serilog.Sinks.File"
],
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"System": "Warning"
}
},
"Enrich": [ "FromLogContext", "WithMachineName", "WithThreadId", "WithEnvironmentName" ],
"Properties": {
"Application": "OrderService"
},
"WriteTo": [
{ "Name": "Console" },
{
"Name": "File",
"Args": {
"path": "logs/app-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 14
}
},
{
"Name": "Seq",
"Args": {
"serverUrl": "https://seq.internal.example.com",
"apiKey": "REPLACE_ME_WITH_APP_SPECIFIC_KEY"
}
}
]
}
}
apiKey out of source control. Set it via Serilog:WriteTo:2:Args:apiKey as an environment variable or a secret manager entry. Example: SERILOG__WRITETO__2__ARGS__APIKEY=abc123.Self-log (debugging Serilog itself)
When Serilog fails to write — bad config, unreachable Seq server, disk full — it swallows the exception by design so it never crashes your app. Enable SelfLog to see those errors during development.
// Very first line of Program.cs, before any logger call Serilog.Debugging.SelfLog.Enable(msg => Console.Error.WriteLine(msg));
Structured Logging
Named placeholders
Every placeholder in a message template becomes a property. Pick names in PascalCase — that's the convention Seq expects and it keeps queries clean.
_logger.LogInformation(
"Charged {Amount} to card ending {Last4} for order {OrderId}",
84.20m, "1234", 4211);
// In Seq you can now query:
// Amount > 50 and Last4 = '1234'
Destructuring with @
Prefixing a placeholder with @ tells Serilog to serialize the whole object as structured data instead of calling ToString() on it.
var order = new { Id = 4211, Total = 84.20m, Items = 3 };
// Without @: "Placed order { Id = 4211, Total = 84.20, Items = 3 }"
_logger.LogInformation("Placed order {Order}", order);
// With @: Order becomes a nested object in Seq — queryable as Order.Total > 50
_logger.LogInformation("Placed order {@Order}", order);
Force scalar with $
The opposite of @. Prefix with $ to force ToString(), even for types Serilog would otherwise destructure.
_logger.LogInformation("User connected from {$Endpoint}", ipEndpoint);
LogContext — properties scoped to a block
LogContext.PushProperty attaches a property to every event logged inside the using block, on the current async flow. Perfect for per-request data.
using (LogContext.PushProperty("CustomerId", customerId))
using (LogContext.PushProperty("Correlation", Guid.NewGuid()))
{
_logger.LogInformation("Processing checkout"); // has CustomerId + Correlation
await _payments.ChargeAsync(...); // any logs inside also have them
_logger.LogInformation("Checkout complete");
}
.Enrich.FromLogContext() in the logger configuration. If your pushed properties never appear in Seq, that's why.BeginScope — the MEL equivalent
If you prefer ILogger.BeginScope (the MEL abstraction), Serilog picks it up and turns scope values into structured properties automatically.
using (_logger.BeginScope(new Dictionary<string, object>
{
["CustomerId"] = customerId,
["Correlation"] = correlationId
}))
{
_logger.LogInformation("Processing checkout");
}
Log levels
| Level | When to use |
|---|---|
| Verbose | Very noisy trace. Off in production. |
| Debug | Diagnostic detail. Off in production by default. |
| Information | Normal operational events: requests, state transitions, key business actions. |
| Warning | Something unexpected but recoverable. Deserves attention, not a page. |
| Error | A failure that affected the operation. Something broke. |
| Fatal | The process is going down. Last-gasp log before crash. |
Common anti-patterns
_logger.LogInformation($"Order {id}") destroys the property. Always: _logger.LogInformation("Order {Id}", id)._logger.LogError(ex, "Failed to charge {OrderId}", id). Serilog captures the stack trace, inner exceptions, and data dictionary.{@HttpRequest} or {@DbContext} serializes graphs you didn't intend. Log the fields that matter, not the object.Enrichers
Enrichers add properties to every log event automatically. You don't want to type MachineName on every log call — an enricher does it once.
Built-in enrichers
| Enricher | Package | Adds |
|---|---|---|
| FromLogContext | core | Everything pushed via LogContext.PushProperty or BeginScope. |
| WithMachineName | Serilog.Enrichers.Environment | MachineName |
| WithEnvironmentName | Serilog.Enrichers.Environment | EnvironmentName (Development/Staging/Production) |
| WithEnvironmentUserName | Serilog.Enrichers.Environment | EnvironmentUserName |
| WithProcessId | Serilog.Enrichers.Process | ProcessId |
| WithThreadId | Serilog.Enrichers.Thread | ThreadId |
| WithCorrelationId | Serilog.Enrichers.CorrelationId | Reads/generates X-Correlation-ID header |
| WithClientIp | Serilog.Enrichers.ClientInfo | Caller's IP (respects X-Forwarded-For) |
Attaching enrichers from JSON
"Enrich": [ "FromLogContext", "WithMachineName", "WithEnvironmentName", "WithProcessId", "WithThreadId" ]
Global properties
Constant properties that should appear on every event go in the Properties block. This is the classic place to tag the application and the version.
"Properties": {
"Application": "OrderService",
"Version": "3.2.1"
}
Application here is optional if you also stamp it via a per-app Seq API key (section 6). The API key wins — it's the enforced version that the app can't accidentally change.Custom enricher: HTTP context
You often want the request path, HTTP method, and authenticated user on every event during a request. Write a small enricher.
public sealed class HttpContextEnricher : ILogEventEnricher
{
private readonly IHttpContextAccessor _accessor;
public HttpContextEnricher(IHttpContextAccessor accessor) => _accessor = accessor;
public void Enrich(LogEvent logEvent, ILogEventPropertyFactory pf)
{
var http = _accessor.HttpContext;
if (http is null) return;
logEvent.AddPropertyIfAbsent(pf.CreateProperty("RequestPath", http.Request.Path.Value));
logEvent.AddPropertyIfAbsent(pf.CreateProperty("RequestMethod", http.Request.Method));
var user = http.User?.Identity?.Name;
if (!string.IsNullOrEmpty(user))
logEvent.AddPropertyIfAbsent(pf.CreateProperty("User", user));
}
}
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddSingleton<ILogEventEnricher, HttpContextEnricher>();
// The .ReadFrom.Services(services) call in your Serilog setup
// picks up any ILogEventEnricher registered in DI.
Custom enricher: build info
public sealed class BuildInfoEnricher : ILogEventEnricher
{
private static readonly string _version =
typeof(BuildInfoEnricher).Assembly.GetName().Version?.ToString() ?? "0.0.0";
private static readonly string _commit =
Environment.GetEnvironmentVariable("GIT_COMMIT") ?? "local";
public void Enrich(LogEvent e, ILogEventPropertyFactory pf)
{
e.AddPropertyIfAbsent(pf.CreateProperty("AppVersion", _version));
e.AddPropertyIfAbsent(pf.CreateProperty("GitCommit", _commit));
}
}
The Seq Sink
How it ships events
All sink arguments
| Argument | Default | Description |
|---|---|---|
| serverUrl | — | Base URL of the Seq server. Required. |
| apiKey | null | Per-app API key. Identifies the source and can force properties. Covered next section. |
| restrictedToMinimumLevel | Verbose | Local floor for the sink. Events below this never leave the app for Seq (regardless of the global minimum). |
| batchPostingLimit | 1000 | Max events per HTTP batch. |
| period | 2s | Flush interval when the batch is not full. |
| bufferBaseFilename | null | Enable durable buffering. Events are written to this path on disk before being shipped. Survives Seq downtime and app restarts. |
| bufferSizeLimitBytes | null | Cap on the disk buffer size. |
| eventBodyLimitBytes | 256 KB | Drop events larger than this. Prevents a runaway log from blowing up a batch. |
| controlLevelSwitch | null | Powerful: a LoggingLevelSwitch that Seq controls remotely. Change the app's live minimum level from the Seq UI without redeploy. Covered in section 8. |
| messageHandler | null | Custom HttpMessageHandler. Useful for corporate proxies or custom TLS. |
| queueSizeLimit | 100000 | Max events held in the in-memory queue before Serilog starts dropping. |
Configured from JSON
{
"Name": "Seq",
"Args": {
"serverUrl": "https://seq.internal.example.com",
"apiKey": "REPLACE_ME",
"restrictedToMinimumLevel": "Information",
"batchPostingLimit": 500,
"period": "00:00:02",
"bufferBaseFilename": "./logs/seq-buffer/buffer",
"bufferSizeLimitBytes": 268435456
}
}
Configured from code
.WriteTo.Seq(
serverUrl: "https://seq.internal.example.com",
apiKey: builder.Configuration["Seq:ApiKey"],
restrictedToMinimumLevel: LogEventLevel.Information,
bufferBaseFilename: "./logs/seq-buffer/buffer")
bufferBaseFilename in production. If Seq is being upgraded, network is flaky, or the app restarts, events pile up on disk and get shipped when Seq comes back — no data loss.Per-App API Keys
This is the piece most teams get wrong. An API key in Seq isn't just an authentication token — it's a policy object attached to every event that arrives with it. One key per application gives you clean filtering, enforced tagging, and blast-radius control that no in-app configuration can match.
OrderService-Prod, OrderService-Staging, PaymentApi-Prod, and so on. Never share.What a Seq API key can do
| Feature | Effect |
|---|---|
| Title | Human-readable label for the key. Shown in Seq UI. |
| Token | The actual secret the app sends in the X-Seq-ApiKey header. |
| Forced properties | The key feature. Properties that Seq stamps on every event received through this key. The app cannot override them. This is how you tag Application, Environment, and Team reliably. |
| Permissions | Usually just Ingest. Never grant Read, Write, or admin permissions to an application key. |
| Minimum level | Server-side floor. Events below this level are rejected before they hit storage. Lets you throttle a noisy app centrally. |
| Filter | Optional expression. Events that don't match are dropped. Rarely needed. |
| Input template | Rewrites incoming events. Advanced; usually the forced properties block is enough. |
Why forced properties matter
You could set "Application": "OrderService" in appsettings.json. But then:
- A dev copies the config to a new service and forgets to change the name — logs land tagged
OrderService. - An overzealous log call passes
Application = "Something"and overrides the enricher. - The property is missing entirely from a legacy component and its events blend in with everyone else.
Forced properties on the API key remove the trust. Seq stamps Application = "OrderService" on every event that came in with that key — always, unconditionally, unforgeable from the app side.
Application is now bulletproof.Creating an API key (Seq UI)
| Step | Action |
|---|---|
| 1 | Log in to Seq as an admin. Open Settings → API Keys. |
| 2 | Click Add API Key. |
| 3 | Title: OrderService-Production. This is what you and other admins see. |
| 4 | Token: click Generate. Copy it now — Seq only shows it once. Store in your secret manager. |
| 5 | Permissions: check only Ingest events. |
| 6 | Minimum level: set to Information in prod, Debug in staging. |
| 7 | Properties applied to events: add key/value pairs. This is the important part. |
Recommended forced properties
Application: OrderService Environment: Production Team: Fulfilment Datacenter: eu-west-1
Keep this list short and consistent across every key. Every app in every env should have the same set of property names — only the values differ. That's what makes cross-app filters work.
Filtering by app in Seq
Once forced properties are in place, filters are trivial and reliable. Seq's search bar accepts expressions:
# Everything from OrderService in prod Application = 'OrderService' and Environment = 'Production' # All errors from any app on the Payments team Team = 'Payments' and @Level = 'Error' # One customer's activity across every service CustomerId = 'cus_42' and Environment = 'Production' # Compare error rates: two apps side by side Application in ['OrderService', 'PaymentApi'] and @Level in ['Error', 'Fatal']
Save filters as signals
A signal in Seq is a saved filter you can toggle from the sidebar. Create one per application:
- OrderService — Prod:
Application = 'OrderService' and Environment = 'Production' - OrderService — Errors: adds
and @Level in ['Error','Fatal'] - Payments team:
Team = 'Payments'
Because signals compose, "click OrderService, then click Errors" filters to just those errors — no typing.
Dashboards, per app
Duplicate one dashboard template per app by baking Application = 'X' into every chart's query. Errors/min, response times, request volumes — all scoped to a single service. Teams get their own view without polluting global metrics.
Rotation and secret storage
Where to store the key:
| Environment | Store the key in |
|---|---|
| Local dev | dotnet user-secrets set Seq:ApiKey "dev_..." |
| CI | CI secret variable, surfaced as env var Seq__ApiKey |
| Kubernetes | Secret mounted as env var |
| Azure / AWS | Key Vault / Secrets Manager, pulled at startup |
Wiring the key in code
// appsettings.json still declares the Seq sink, but the apiKey stays out.
// We inject it from configuration/env vars at bootstrap.
builder.Host.UseSerilog((context, services, config) =>
{
config
.ReadFrom.Configuration(context.Configuration)
.ReadFrom.Services(services)
.Enrich.FromLogContext();
// Override the API key from a secure source.
var apiKey = context.Configuration["Seq:ApiKey"];
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Seq API key is not configured.");
config.WriteTo.Seq(
serverUrl: context.Configuration["Seq:ServerUrl"]!,
apiKey: apiKey,
bufferBaseFilename: "./logs/seq-buffer/buffer");
});
Correlation & Tracing
Per-app API keys tell you which service logged something. Correlation IDs tell you which request logged it — across every service that request touched. You need both.
The pattern
- Middleware reads
traceparent(W3C) or a custom header from the incoming request. - If missing, it generates one.
- The value is pushed into
LogContext, so every event during this request carries it. - Outbound HTTP calls (via a
DelegatingHandler) attach the same header. Downstream services pick it up and repeat the process.
Middleware
public sealed class CorrelationMiddleware
{
private const string Header = "X-Correlation-ID";
private readonly RequestDelegate _next;
public CorrelationMiddleware(RequestDelegate next) => _next = next;
public async Task InvokeAsync(HttpContext ctx)
{
var correlationId = ctx.Request.Headers.TryGetValue(Header, out var v)
&& !string.IsNullOrWhiteSpace(v)
? v.ToString()
: Guid.NewGuid().ToString("N");
ctx.Response.Headers[Header] = correlationId;
using (LogContext.PushProperty("CorrelationId", correlationId))
using (LogContext.PushProperty("TraceId", Activity.Current?.TraceId.ToString()))
{
await _next(ctx);
}
}
}
// Program.cs
app.UseMiddleware<CorrelationMiddleware>();
app.UseSerilogRequestLogging(); // request logging sees the correlation too
Propagating across HTTP calls
public sealed class CorrelationHandler : DelegatingHandler
{
private readonly IHttpContextAccessor _accessor;
public CorrelationHandler(IHttpContextAccessor accessor) => _accessor = accessor;
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request, CancellationToken ct)
{
var correlationId = _accessor.HttpContext?.Response.Headers["X-Correlation-ID"].ToString();
if (!string.IsNullOrEmpty(correlationId))
request.Headers.TryAddWithoutValidation("X-Correlation-ID", correlationId);
return base.SendAsync(request, ct);
}
}
// Program.cs
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient<CorrelationHandler>();
builder.Services
.AddHttpClient("payments", c => c.BaseAddress = new Uri("https://payments.internal"))
.AddHttpMessageHandler<CorrelationHandler>();
Finding the whole story in Seq
Once every service tags its events with the same CorrelationId, tracing a request across your fleet is one query:
CorrelationId = '9f2b8d1c4e7a4a67b1'
Seq shows the events in timestamp order, interleaved across every Application. Click any event to see the full property bag; group by Application to see the call flow.
TraceId as the correlation. Seq surfaces TraceId and SpanId as first-class properties and can link to your APM.Production Concerns
Dynamic level control
When something breaks in production, you want Debug for five minutes on one app — without a redeploy. Seq can flip the switch remotely if you wire up a LoggingLevelSwitch.
var levelSwitch = new LoggingLevelSwitch(LogEventLevel.Information);
Log.Logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(levelSwitch)
.Enrich.FromLogContext()
.WriteTo.Seq(
serverUrl: "https://seq.internal.example.com",
apiKey: apiKey,
controlLevelSwitch: levelSwitch) // Seq now controls this
.CreateLogger();
In Seq: Settings → API Keys → your key → Minimum level. Change it, save, and within a few seconds every instance of that app starts logging at the new level. Reset when you're done.
Minimum level overrides
Namespaces let you keep the framework quiet while your app logs at Information:
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore.Mvc": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"Microsoft.AspNetCore.Hosting": "Warning",
"Microsoft.AspNetCore.Routing": "Warning",
"System.Net.Http.HttpClient": "Warning"
}
}
Redacting secrets
Never log passwords, full card numbers, tokens, or PII. Serilog gives you three ways to enforce it:
- Destructuring policy: tell Serilog how to serialize a specific type — drop or mask fields.
- Custom enricher: walk the event's properties and mask anything matching a pattern.
- Don't log it at all: the safest option. Log the operation, not the payload.
public sealed class CustomerDestructurer : IDestructuringPolicy
{
public bool TryDestructure(object value, ILogEventPropertyValueFactory f, out LogEventPropertyValue? result)
{
if (value is not Customer c) { result = null; return false; }
result = f.CreatePropertyValue(new
{
c.Id,
EmailMasked = Mask(c.Email),
// Password, SSN, etc: not included at all
}, destructureObjects: true);
return true;
}
private static string Mask(string s) =>
s.Length < 4 ? "***" : s[..2] + "***" + s[^2..];
}
// Serilog config
.Destructure.With<CustomerDestructurer>()
Fallback file sink
Always keep a local file sink alongside Seq. If Seq is unreachable and the disk buffer is lost, you still have text logs on the box for last-resort investigation.
Log volume budget
| Rule of thumb | Number |
|---|---|
| Events per request | 3–10 (request-in, key business steps, request-out) |
| Event size | Under 4 KB. Warn if larger; drop if over 256 KB (eventBodyLimitBytes). |
| Cost driver in Seq | Property count and cardinality, not raw event count. Avoid logging unbounded string values as properties (URLs with random IDs, error messages with GUIDs) — they explode the index. |
Environment matrix
| Development | Staging | Production | |
|---|---|---|---|
| Default level | Debug | Information | Information |
| Seq API key | shared dev key | App-Staging | App-Production |
| Buffer file | optional | on | on |
| File sink | optional | on (rolling daily) | on (rolling daily, 14 day retention) |
| Console sink | on (human-friendly) | on (JSON) | on (JSON, for container logs) |
Checklist before shipping
- API key is unique per app per environment, stored in a secret manager, and has only
Ingestpermission. - Forced properties on the key include
Application,Environment, andTeam. bufferBaseFilenameis set and writable.Log.CloseAndFlush()is in thefinallyblock.- Framework namespaces are overridden down to
Warning. - Correlation middleware and
CorrelationHandlerare wired for every outbound HTTP client. - A signal per app exists in Seq. A signal per team exists in Seq.
- SelfLog is off in production (or routed to stderr only).