Skip to content

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.Logging is 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 TraceIdentifier and 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

_log.LogInformation("Order {OrderId} placed for {Total:C}", orderId, total);

{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

_log.LogInformation($"Saving order {order.Id}");   // unstructured; renders before filter

✅ Correct: template

_log.LogInformation("Saving order {OrderId}", order.Id);

❌ Wrong: silent catch

try { /* ... */ } catch { /* nothing */ }

Lost context; bug hunting nightmare.

✅ Correct: log + rethrow or recover

catch (Exception ex)
{
    _log.LogError(ex, "Failed during {Op}", op);
    throw;   // or handle
}

❌ Wrong: logging full request body

_log.LogInformation("Got request: {Body}", JsonSerializer.Serialize(req));   // PII leak

✅ Correct: log shape, not contents

_log.LogInformation("Got {ItemCount} items from user {UserId}", req.Items.Count, req.UserId);

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 bodiesPII leak.
  • ⚠️ Same EventId reused for unrelated events — confusion.
  • ⚠️ No correlation ID — can't stitch logs.
  • ⚠️ Trace level enabled in prod — disk full.

Further reading