Structured Logging
Key Points
- Structured logs = key-value data, not strings. Each log event has named properties:
{ message, level, timestamp, userId, orderId, ... }. Indexed, searchable, alertable. Microsoft.Extensions.Loggingis the abstraction. Built-in providers: console, debug, EventSource. Plug Serilog or NLog for production-grade sinks.- Use
[LoggerMessage]source generator in .NET 6+ — zero-allocation, compile-time strongly-typed log methods. Faster than string interpolation; supports structured properties. - Log levels: Trace (extremely verbose) · Debug (dev) · Information (events) · Warning (recoverable) · Error (failed) · Critical (process-threatening).
- Always include correlation/trace IDs so logs from one request stitch together. ASP.NET Core auto-includes
TraceIdentifierand W3C TraceContext.
Concepts (deep dive)
The structured logging contract
// ❌ String concatenation — unstructured
_log.LogInformation($"User {userId} placed order {orderId}");
// ✅ Structured — properties indexed
_log.LogInformation("User {UserId} placed order {OrderId}", userId, orderId);
Same console output (User 42 placed order ORD-123), but the second emits a structured event:
{
"@t": "2026-04-26T12:00:00Z",
"@l": "Information",
"@mt": "User {UserId} placed order {OrderId}",
"UserId": 42,
"OrderId": "ORD-123",
"@i": "...",
"TraceId": "00-..."
}
In Seq / Splunk / Datadog: filter by UserId = 42 directly. With concatenation, you'd need regex.
The hierarchy
ILogger<T> // typed logger; category = T's full name
ILoggerFactory // creates loggers
ILoggerProvider // backing — Serilog, NLog, console
ILogger // raw, untyped
Inject ILogger<MyClass>; the logger's category is the class name.
Log levels and filtering
// appsettings.json
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore.Database.Command": "Information",
"MyApp.Domain": "Debug"
}
}
}
Filtered out levels are cheap — no message formatting, no allocation (with [LoggerMessage] or IsEnabled checks).
[LoggerMessage] source generator (do this)
public partial class OrderService(ILogger<OrderService> log)
{
[LoggerMessage(EventId = 1001, Level = LogLevel.Information,
Message = "User {UserId} placed order {OrderId}")]
private partial void LogOrderPlaced(int userId, string orderId);
public void Place(int userId)
{
// ...
LogOrderPlaced(userId, orderId);
}
}
Generator emits an optimized method. Benefits: - Zero allocations on filtered-out logs. - Compile-time check that placeholders match parameters. - EventId for log correlation. - 5–10x faster than string formatting at high volume.
For .NET 8+ this is the recommended approach.
Serilog setup
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithCorrelationId()
.WriteTo.Console(new RenderedCompactJsonFormatter())
.WriteTo.Seq("http://seq:5341"));
Serilog adds: - Sinks: console, file, Seq, Splunk, Elastic, Datadog, Azure App Insights, etc. - Enrichers: machine name, thread ID, correlation, trace context. - LogContext.PushProperty for ambient enrichment.
using (LogContext.PushProperty("OrderId", orderId))
{
_log.LogInformation("Processing");
// every log within scope has OrderId attached
}
Scopes
using (_log.BeginScope(new Dictionary<string, object> { ["OrderId"] = orderId, ["UserId"] = userId }))
{
_log.LogInformation("Step 1");
_log.LogInformation("Step 2");
}
All logs within scope inherit the properties. Useful for grouping logs in a request/operation.
Correlation / trace ID
ASP.NET Core sets TraceIdentifier per request. With OpenTelemetry, Activity.Current.TraceId is the W3C trace ID — propagated across services via traceparent header.
public class CorrelationMiddleware
{
public async Task Invoke(HttpContext ctx, RequestDelegate next)
{
using (LogContext.PushProperty("CorrelationId", ctx.TraceIdentifier))
await next(ctx);
}
}
In modern setups, OpenTelemetry handles this automatically — every log gets the active TraceId and SpanId.
What to log
- Application events: order placed, payment processed, user signed up.
- Errors with context: stack trace + correlation ID + user ID + input summary (no PII!).
- Boundary calls: outbound HTTP, DB query latency (sampled).
- Business decisions: "skipping fraud check because amount < $10".
What NOT to log
- Passwords, tokens, keys — ever.
- PII in plain — emails, names, SSNs unless required and protected.
- Request bodies by default — they may contain PII.
- Every method entry/exit — log noise; use Trace level if needed.
Log message templates
{OrderId} is a property name (not a positional arg). {Total:C} is a format. Properties named — not numbered.
Bad:
_log.LogInformation("Order {0} placed for {1}", orderId, total); // numeric — works but loses semantic
Exceptions
try { /* ... */ }
catch (Exception ex)
{
_log.LogError(ex, "Failed to place order for {UserId}", userId);
throw;
}
Always pass ex as first arg (after level). Sinks render the stack trace.
Cost / performance
In a hot path, format-and-discard logs are expensive even at Trace level if not filtered. Use:
if (_log.IsEnabled(LogLevel.Debug))
_log.LogDebug("Slow path: {Stats}", ComputeStats()); // ComputeStats not called when Debug off
Or use [LoggerMessage] — handles this automatically.
Sampling
For very high-volume apps:
// Serilog sub-logger / OpenTelemetry sampling at the export side
.WriteTo.Logger(lc => lc
.Filter.With<RandomSampler>(rate: 0.1) // 10% of debug logs
.WriteTo.Seq(...))
Sinks comparison
| Sink | Use case |
|---|---|
| Console (JSON) | Container-native; pipe to log aggregator |
| File | On-prem, archived |
| Seq | .NET-friendly local/team aggregator |
| Datadog / Splunk / New Relic | SaaS aggregators |
| Application Insights | Azure-native |
| Elastic / Loki | Self-hosted |
| OTLP | OpenTelemetry collector → anywhere |
For 2026, emit JSON to stdout + ship via OTLP collector is the cloud-native default.
Code: correct vs wrong
❌ Wrong: string interpolation
✅ Correct: template
❌ Wrong: silent catch
Lost context; bug hunting nightmare.
✅ Correct: log + rethrow or recover
❌ Wrong: logging full request body
✅ Correct: log shape, not contents
Design patterns for this topic
Pattern 1 — "[LoggerMessage] source generator"
- Intent: zero-allocation, type-safe logs.
Pattern 2 — "Structured properties everywhere"
- Intent: indexable, alertable.
Pattern 3 — "Scopes for ambient correlation"
- Intent: request-scoped properties auto-attach.
Pattern 4 — "Stdout JSON + OTLP collector"
- Intent: vendor-neutral pipeline.
Pattern 5 — "Sampling at high volume"
- Intent: keep useful signal; cap cost.
Pros & cons / trade-offs
| Aspect | Pros | Cons |
|---|---|---|
| Structured logs | Searchable; alertable | More setup |
[LoggerMessage] SG | Fastest; type-safe | Boilerplate |
| Serilog | Rich sinks; mature | Extra dep |
| Stdout JSON | Container-native | Needs aggregator |
When to use / when to avoid
- Always structured.
- Always include correlation/trace ID.
- Use
[LoggerMessage]for hot-path logs. - Avoid logging secrets / PII.
- Avoid verbose method-entry logs.
Interview Q&A
Q1. Why structured logs? Indexable properties. Filter by UserId=42 directly. With strings, you regex.
Q2. What's [LoggerMessage]? Source-generated logging method. Zero allocations on filtered-out levels; compile-time validation; faster.
Q3. BeginScope vs LogContext.PushProperty? BeginScope (Microsoft.Extensions.Logging) attaches properties to logs in the using block. LogContext.PushProperty is the Serilog equivalent.
Q4. Where put correlation ID? Middleware: read/generate at request start; push to LogContext; attach to outbound calls (header propagation).
Q5. Sane log levels? Information for events; Warning for recoverable issues; Error for failures; Trace/Debug usually off in prod.
Q6. Why is LogInformation($"...") bad? Interpolation builds the string before the level is checked, allocating even when filtered.
Q7. How prevent password logging? Audit code; mark sensitive properties with [LogPropertiesAttribute(Skip)] or use ScruberOptions in Serilog enricher.
Q8. EventId in [LoggerMessage]? Stable identifier across log entries — useful for correlating same logical event in queries.
Q9. Console JSON vs Seq vs OTLP? Console JSON for container-native; Seq for local/team aggregation; OTLP for vendor-neutral pipelines.
Q10. How sample logs? Sink-side sampling (Serilog sub-logger), or OpenTelemetry sampling on export. Drop low-value Debug at high volume.
Q11. Logging vs metrics vs traces? Logs = events with rich context. Metrics = aggregated numbers. Traces = causal request flow. All three are needed.
Q12. Scope properties — performance? Each scope allocates. For tight loops, prefer single log call with all properties over many scoped logs.
Gotchas / common mistakes
- ⚠️ String interpolation in log calls.
- ⚠️ Logging exceptions without
ex— no stack trace. - ⚠️ Logging request bodies — PII leak.
- ⚠️ Same EventId reused for unrelated events — confusion.
- ⚠️ No correlation ID — can't stitch logs.
- ⚠️ Trace level enabled in prod — disk full.