DEV Community

Cover image for Structured Logging: Machine-Readable Logs for Real Analysis
Rhuturaj Takle
Rhuturaj Takle

Posted on

Structured Logging: Machine-Readable Logs for Real Analysis

Structured Logging: Machine-Readable Logs for Real Analysis

A practical guide to structured logging — writing log entries as machine-readable, queryable data rather than free-text strings — covering message templates, Serilog and the built-in .NET logging abstraction, sinks, enrichment, correlation with OpenTelemetry traces, and the query-ability payoff structured logs provide over plain text.


Table of Contents

  1. Introduction
  2. The Problem with Unstructured Logs
  3. Message Templates: Structure Without Sacrificing Readability
  4. ASP.NET Core's Built-In Logging Abstraction
  5. Serilog: The De Facto Standard for .NET
  6. Log Levels and When to Use Them
  7. Enrichment: Attaching Context Automatically
  8. Sinks: Where Structured Logs Actually Go
  9. Correlating Logs with Traces
  10. Querying Structured Logs
  11. What Not to Log
  12. Performance Considerations
  13. Common Pitfalls
  14. Quick Reference Table
  15. Conclusion

Introduction

Structured logging means writing log entries as data — a set of named fields with typed values — rather than as a single, free-text sentence that happens to contain useful information buried inside it. The practical difference is enormous: a structured log entry can be filtered, aggregated, and queried precisely by any of its fields, while a plain-text log entry can only really be searched by substring matching and hoped-for regular expressions. This guide is the logging-specific companion to this series' OpenTelemetry guide — where that guide covers the broader three-pillars observability framework, this one goes deep specifically on doing the logging pillar well in .NET.

// Unstructured: a human can read it, a machine can only guess at its meaning
logger.LogInformation($"Order {orderId} placed by customer {customerId} for ${total}");

// Structured: the same information, but as genuinely queryable, typed fields
logger.LogInformation("Order {OrderId} placed by customer {CustomerId} for {Total:C}", orderId, customerId, total);
Enter fullscreen mode Exit fullscreen mode

Both lines produce similarly readable console output — the difference is entirely in what happens after that log entry leaves the console, when it lands in a system built to actually query it.


1. The Problem with Unstructured Logs

String interpolation destroys the data before it's ever logged

// ❌ By the time this string exists, "1001" is just a substring — its meaning as an OrderId is gone
logger.LogInformation($"Order {orderId} failed validation: {reason}");
Enter fullscreen mode Exit fullscreen mode

Once values are interpolated directly into a string, all you have afterward is text — a log aggregation system receiving this line has no reliable way to know that the first number is an order ID rather than, say, a customer ID or a dollar amount that happens to also be 1001. Any attempt to query "show me every failed validation for order 1001" degrades into a fragile substring or regex search, hoping the format never changes and that 1001 doesn't coincidentally appear as a customer ID somewhere else in the same log stream.

What this actually costs you in practice

Question: "How many orders failed validation last Tuesday, broken down by failure reason?"

With unstructured logs: write a regex to extract the reason from free text, hope it's consistent
                          across every place this log line is emitted, across every service version
                          that's ever run in production, then manually aggregate the results

With structured logs:   SELECT reason, COUNT(*) FROM logs
                          WHERE message_template = 'Order {OrderId} failed validation: {Reason}'
                            AND timestamp BETWEEN '2026-07-28' AND '2026-07-29'
                          GROUP BY reason
Enter fullscreen mode Exit fullscreen mode

This is the entire practical case for structured logging in one comparison — a question that's a straightforward aggregation query against structured data becomes a fragile, manual text-parsing exercise against unstructured data, and that gap only widens as log volume and the number of services producing logs grows.

Structured logging is what makes logs actually useful at scale

A single service producing a modest volume of logs can sometimes get away with grepping plain text by hand. The moment a system spans more than a handful of services (exactly the distributed, event-driven, messaging-heavy systems covered throughout this series), unstructured logs stop being a practical tool for understanding what's actually happening in production — structured logging is the foundational discipline that makes centralized log aggregation and analysis (Section 9) genuinely work, rather than just accumulating text nobody can efficiently query.


2. Message Templates: Structure Without Sacrificing Readability

The key insight: keep the human-readable template, separate the values

logger.LogInformation("Order {OrderId} placed by customer {CustomerId} for {Total:C}", orderId, customerId, total);
Enter fullscreen mode Exit fullscreen mode

This is the core mechanic that makes structured logging in .NET (and Serilog specifically) work well without sacrificing the readability developers actually want when scanning logs directly — the message template ("Order {OrderId} placed by customer {CustomerId} for {Total:C}") stays a fixed, human-readable string, while {OrderId}, {CustomerId}, and {Total} are named placeholders, each bound to its corresponding argument as a distinct, typed, queryable field — not just interpolated into an opaque string.

What actually gets stored

{
  "Timestamp": "2026-08-01T14:32:01Z",
  "Level": "Information",
  "MessageTemplate": "Order {OrderId} placed by customer {CustomerId} for {Total:C}",
  "RenderedMessage": "Order 1001 placed by customer 42 for $149.97",
  "Properties": {
    "OrderId": 1001,
    "CustomerId": 42,
    "Total": 149.97
  }
}
Enter fullscreen mode Exit fullscreen mode

A structured logging library stores (or exports) both the rendered, human-readable message and the original template plus each individual named property as its own field — this is precisely what makes the aggregation query from Section 1 possible: you can group by Properties.OrderId directly, or group by the MessageTemplate itself to find every occurrence of "this specific kind of log event," regardless of what specific order ID or customer ID happened to appear in any individual instance.

Property names, not positional arguments, drive the structure

// ✅ Named placeholders in the template drive which property name each value gets
logger.LogWarning("Payment failed for order {OrderId}: {FailureReason}", orderId, reason);

// ❌ Mismatched order between template and arguments produces confusingly mislabeled properties
logger.LogWarning("Payment failed for order {FailureReason}: {OrderId}", orderId, reason); // labels swapped!
Enter fullscreen mode Exit fullscreen mode

Because the placeholder names in the template — not just their positions — determine the resulting property names, getting the template and argument order aligned correctly is what actually determines the resulting structured data's correctness; a mismatch here produces log entries with subtly, silently wrong field names, which can go unnoticed for a long time since the rendered message often still reads plausibly.


3. ASP.NET Core's Built-In Logging Abstraction

ILogger<T>: the abstraction every .NET logging library builds on

public class OrderService
{
    private readonly ILogger<OrderService> _logger;
    public OrderService(ILogger<OrderService> logger) => _logger = logger;

    public async Task<Order> PlaceOrderAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Placing order for customer {CustomerId} with {ItemCount} items",
            request.CustomerId, request.Items.Count);

        var order = await _repository.CreateAsync(request);

        _logger.LogInformation("Order {OrderId} placed successfully", order.Id);
        return order;
    }
}
Enter fullscreen mode Exit fullscreen mode

ILogger<T> is built into the .NET runtime itself (not a third-party library) and already produces structured log entries out of the box — the {CustomerId}/{ItemCount} message template syntax shown above works identically whether the underlying logging provider is the built-in console logger, Serilog (Section 4), or any other ILogger-compatible provider, because the structured template syntax is part of the abstraction itself, not a Serilog-specific feature.

Built-in providers vs. richer third-party providers

builder.Logging.AddConsole();
builder.Logging.AddDebug();
builder.Logging.AddEventLog(); // Windows Event Log
Enter fullscreen mode Exit fullscreen mode

The built-in console/debug providers are genuinely structured (the message template and properties exist internally), but their default output formatting is still largely human-readable text — getting genuinely structured output (JSON, or a purpose-built log aggregation format) generally means configuring a richer provider like Serilog (Section 4) or the OpenTelemetry logging exporter covered in this series' companion guide, both of which plug into this same ILogger abstraction rather than replacing it.

Scopes: attaching context across multiple log calls

using (_logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = order.Id }))
{
    _logger.LogInformation("Validating order");
    await ValidateAsync(order);
    _logger.LogInformation("Reserving inventory");
    await ReserveInventoryAsync(order);
} // every log call within this scope automatically includes OrderId, without repeating it in every message
Enter fullscreen mode Exit fullscreen mode

A logging scope attaches a set of properties to every log entry emitted within it, without needing to repeat those properties in every individual log call — useful for context that's relevant across a whole operation (an order ID, a request ID) rather than specific to one particular log message.


4. Serilog: The De Facto Standard for .NET

Why Serilog specifically

Serilog has become the dominant structured logging library in the .NET ecosystem — not by replacing ILogger<T>, but by providing a considerably richer implementation of it: a large ecosystem of sinks (Section 7) for exporting to virtually any log storage/analysis backend, a flexible enrichment pipeline (Section 6), and first-class support for structured, complex object logging that goes beyond the built-in providers' capabilities.

Basic setup

var builder = WebApplication.CreateBuilder(args);

builder.Host.UseSerilog((context, services, configuration) => configuration
    .ReadFrom.Configuration(context.Configuration)
    .ReadFrom.Services(services)
    .Enrich.FromLogContext()
    .WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter())
    .WriteTo.Seq("http://localhost:5341"));

var app = builder.Build();
Enter fullscreen mode Exit fullscreen mode

Once configured, application code continues using the same ILogger<T> shown in Section 3 entirely unchanged — Serilog slots in underneath the standard abstraction, meaning adopting it doesn't require rewriting existing logging calls, only the startup configuration.

Configuration via appsettings.json

{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": { "Microsoft.AspNetCore": "Warning" }
    },
    "WriteTo": [
      { "Name": "Console" },
      { "Name": "Seq", "Args": { "serverUrl": "http://localhost:5341" } }
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

Serilog's configuration can live entirely in appsettings.json (via ReadFrom.Configuration), letting log levels and sink destinations vary per environment (matching the environment-specific configuration patterns covered in this series' ASP.NET Core guide) without recompiling — a production environment might route to Seq or an OpenTelemetry Collector, while local development just writes readable text to the console.

Logging complex objects with destructuring

logger.LogInformation("Processing order {@Order}", order);
Enter fullscreen mode Exit fullscreen mode

The @ destructuring operator tells Serilog to serialize the entire object's structure (its properties, recursively) as part of the log entry, rather than just calling .ToString() on it — genuinely useful for capturing rich context about a complex object in one log call, though worth using deliberately (Section 11) since it can produce large log entries and risks accidentally capturing sensitive fields (Section 10) if the object contains any.


5. Log Levels and When to Use Them

The standard level hierarchy

logger.LogTrace("Entering method with parameters {Params}", parameters);       // finest-grained, rarely enabled in production
logger.LogDebug("Cache miss for key {CacheKey}", key);                          // diagnostic detail, useful in development/troubleshooting
logger.LogInformation("Order {OrderId} placed", orderId);                       // routine, expected events worth recording
logger.LogWarning("Retry attempt {Attempt} for {Operation}", attempt, opName);   // something unexpected, but recovered from
logger.LogError(exception, "Failed to process order {OrderId}", orderId);       // an operation failed
logger.LogCritical("Database connection pool exhausted");                        // the application itself may be unable to continue functioning
Enter fullscreen mode Exit fullscreen mode

Choosing the right level deliberately

A common, costly mistake is treating log levels as an afterthought rather than a deliberate signal — logging routine, expected events at Warning or Error trains everyone to ignore those levels (since they fire constantly and rarely indicate a real problem), while logging genuinely actionable failures at Information means they get lost in routine noise and never trigger the alerting they should.

A practical rule of thumb per level

Level Use for Production default
Trace Extremely fine-grained diagnostic detail, method entry/exit Usually disabled entirely
Debug Diagnostic detail useful when actively troubleshooting a specific issue Usually disabled, enabled temporarily when needed
Information Routine, expected events worth a durable record (a request completed, an order was placed) Enabled
Warning Something unexpected happened, but the system recovered or degraded gracefully Enabled, often the starting point for anomaly alerting
Error An operation failed and likely needs attention Enabled, typically wired to alerting
Critical The application itself may be unable to continue functioning correctly Enabled, typically wired to urgent/paging alerting

Setting levels per namespace, not just globally

{
  "Serilog": {
    "MinimumLevel": {
      "Default": "Information",
      "Override": {
        "Microsoft.AspNetCore": "Warning",
        "MyApp.Payments": "Debug"
      }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Framework-level logging (ASP.NET Core's own internal request pipeline logging, for instance) is often genuinely noisy at Information level — overriding specific namespaces to a stricter minimum level (while perhaps temporarily loosening a specific area under active investigation, like MyApp.Payments above) gives fine-grained control over signal-to-noise ratio without a single, blunt global setting.


6. Enrichment: Attaching Context Automatically

The problem enrichment solves

Manually adding the same contextual properties (which server, which request, which correlation ID) to every single log call throughout an application would be repetitive and error-prone — enrichment attaches this context automatically, once configured, to every log entry without the application code needing to remember to include it.

Common enrichers

.Enrich.FromLogContext()
.Enrich.WithMachineName()
.Enrich.WithEnvironmentName()
.Enrich.WithProcessId()
.Enrich.WithThreadId()
Enter fullscreen mode Exit fullscreen mode
// FromLogContext works together with BeginScope/LogContext.PushProperty to enrich
// every log entry within a given scope automatically
using (LogContext.PushProperty("OrderId", order.Id))
{
    logger.LogInformation("Validating order"); // automatically includes OrderId
    logger.LogInformation("Reserving inventory"); // automatically includes OrderId too
}
Enter fullscreen mode Exit fullscreen mode

Enrich.FromLogContext() is what actually makes the BeginScope-style pattern from Section 3 (and Serilog's own LogContext.PushProperty) function — every log call made while a given property is "pushed" onto the ambient log context automatically includes it, without needing to pass it explicitly to each individual log statement.

Enriching with trace context: the bridge to OpenTelemetry

.Enrich.WithSpan() // Serilog.Enrichers.Span — attaches the active TraceId/SpanId to every log entry
Enter fullscreen mode Exit fullscreen mode

This is the concrete mechanism behind the log-trace correlation covered in this series' OpenTelemetry guide — an enricher automatically stamps every log entry with the currently active trace and span ID (from .NET's Activity.Current), so a log line can always be traced back to the exact distributed operation it occurred within, without any manual plumbing at each individual log call site.

Custom, application-specific enrichment

public class TenantEnricher : ILogEventEnricher
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    public void Enrich(LogEvent logEvent, ILogEventPropertyFactory propertyFactory)
    {
        var tenantId = _httpContextAccessor.HttpContext?.User.FindFirstValue("tid");
        if (tenantId is not null)
        {
            logEvent.AddPropertyIfAbsent(propertyFactory.CreateProperty("TenantId", tenantId));
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

For applications with genuinely important cross-cutting context beyond what built-in enrichers cover — the current tenant in a multi-tenant system (per this series' RBAC/Policy-Based Authorization guide's multi-tenant discussion), the current authenticated user, the API version being served — a custom enricher attaches it automatically to every log entry, ensuring this context is never accidentally missing from a log line because a developer forgot to include it manually.


7. Sinks: Where Structured Logs Actually Go

Console and file sinks: the simplest starting point

.WriteTo.Console(new Serilog.Formatting.Json.JsonFormatter())
.WriteTo.File("logs/log-.json", rollingInterval: RollingInterval.Day,
    formatter: new Serilog.Formatting.Json.JsonFormatter())
Enter fullscreen mode Exit fullscreen mode

Writing structured JSON to the console (rather than a human-formatted text line) is often the right default in containerized environments (per this series' Docker and Kubernetes/Helm guides), where a container orchestration platform's own log collection typically captures stdout and forwards it to a centralized aggregation system — the JSON structure is what that downstream system actually needs to parse and index the logs meaningfully.

Log aggregation platform sinks

.WriteTo.Seq("http://localhost:5341")
.WriteTo.Elasticsearch(new ElasticsearchSinkOptions(new Uri("http://localhost:9200")))
.WriteTo.ApplicationInsights(telemetryConfiguration, TelemetryConverter.Traces)
Enter fullscreen mode Exit fullscreen mode

Serilog's sink ecosystem covers essentially every major log aggregation platform directly — Seq (a lightweight, developer-friendly structured log server), Elasticsearch (commonly paired with Kibana for visualization), Azure Application Insights, Datadog, and dozens more — letting the choice of where logs actually go be a configuration change rather than requiring different logging code for different environments or backends.

Routing through an OpenTelemetry Collector instead of a Serilog-specific sink

.WriteTo.OpenTelemetry(options => options.Endpoint = "http://localhost:4317")
Enter fullscreen mode Exit fullscreen mode

As covered in this series' OpenTelemetry guide, routing logs through OTLP to a Collector rather than a Serilog-specific sink keeps logging on the same vendor-neutral export path as traces and metrics — a genuinely reasonable default for teams who've already adopted OpenTelemetry more broadly, since it means one Collector configuration governs where all three pillars of telemetry ultimately land, rather than logs following a separate configuration path from traces and metrics.

Multiple sinks simultaneously

.WriteTo.Console()
.WriteTo.Seq("http://localhost:5341")
.WriteTo.File("logs/errors-.json", restrictedToMinimumLevel: LogEventLevel.Error, rollingInterval: RollingInterval.Day)
Enter fullscreen mode Exit fullscreen mode

A single log call can be written to several sinks at once, each potentially with its own minimum level filter — a common pattern is console output for local development visibility, a central aggregation sink for production analysis, and a separate, error-only file sink as a lightweight local backstop even if the central aggregation system is temporarily unreachable.


8. Correlating Logs with Traces

The specific mechanism, restated concretely

Log entry: { "Message": "Payment failed", "TraceId": "4bf92f3577b34da6a3ce929d0e0e4736", "SpanId": "00f067aa0ba902b7" }
Enter fullscreen mode Exit fullscreen mode

As introduced in Section 6 and covered in depth in this series' OpenTelemetry guide, attaching the active TraceId/SpanId to every structured log entry is what lets a developer pivot directly from "I found this error log line" to "here's the complete distributed trace of everything that happened in the request/event chain this log line was part of" — arguably the single most valuable payoff structured logging provides once combined with distributed tracing.

Why this specifically requires structured logging, not just tracing

If logs were unstructured free text, even with a trace ID technically present somewhere in the string, reliably extracting and using it to pivot into a tracing backend would require the same fragile text-parsing this entire guide argues against — structured logging is what makes the trace ID a genuinely first-class, directly queryable field rather than a substring you'd need to regex out.


9. Querying Structured Logs

The kinds of questions structured logs make tractable

-- Seq's query language, or an equivalent structured query against any log aggregation backend
SELECT Properties.CustomerId, Count(*)
FROM Logs
WHERE Level = 'Warning' AND MessageTemplate LIKE 'Payment failed%'
  AND Timestamp > Now() - 1h
GROUP BY Properties.CustomerId
ORDER BY Count(*) DESC
Enter fullscreen mode Exit fullscreen mode

"Which customers are experiencing the most payment failures in the last hour" is a straightforward aggregation query against structured log properties — the exact kind of question that's genuinely impractical to answer reliably against unstructured text logs, and precisely the payoff this guide has been building toward since Section 1.

Filtering by structured properties directly

Level = "Error" AND Properties.OrderId = 1001
Enter fullscreen mode Exit fullscreen mode

Because OrderId is a genuine, typed field (not a substring within a larger message), filtering to "every log entry related to order 1001, regardless of which service emitted it or what the specific message said" is a precise, reliable query rather than a hopeful substring match that might also incidentally match an unrelated log line containing the same digits.

Dashboards and alerting built directly on structured queries

Structured log queries are also what typically power log-based alerting rules and dashboards in a production observability setup ("alert if Properties.FailureReason = 'InsufficientFunds' occurs more than 50 times in 5 minutes") — connecting directly to the security event logging and monitoring discipline covered in this series' OWASP Top 10 guide, where the actionable value of security logging specifically depends on being able to detect a pattern across many log entries, not just retain each one individually.


10. What Not to Log

The overlap with this series' security guidance

As covered in this series' JWT Validation, Secret Management, and OWASP Top 10 guides, certain categories of data should never appear in a log entry — raw authentication tokens, passwords, full credit card numbers, and other secrets. Structured logging doesn't change this guidance; if anything, it raises the stakes slightly, since a structured field is more queryable and therefore more discoverable than the same sensitive value buried in unstructured text.

// ❌ Even structured, this puts a genuinely sensitive value into a durable, queryable log store
logger.LogInformation("Authenticated with token {Token}", rawJwt);

// ✅ Log identifying, non-sensitive context instead
logger.LogInformation("Authenticated user {UserId} via token {TokenId}", userId, tokenJti);
Enter fullscreen mode Exit fullscreen mode

Destructuring risk: accidentally logging an entire object, secrets included

// ❌ If `user` has a PasswordHash or a stored ApiKey property, @-destructuring captures it too
logger.LogInformation("Updated user {@User}", user);
Enter fullscreen mode Exit fullscreen mode

The @ destructuring operator (Section 4) is convenient but genuinely risky if applied to a domain object that happens to carry a sensitive field — worth explicitly reviewing which objects are safe to destructure wholesale versus which should only have specific, deliberately chosen properties logged individually.

Personally identifiable information (PII) and compliance

Beyond outright secrets, logging PII (full names, email addresses, physical addresses) at high volume, retained indefinitely in a log aggregation system, can itself become a compliance concern (GDPR, CCPA, and similar regulations) depending on jurisdiction and data handling policy — worth a deliberate, reviewed decision about what identifying information genuinely needs to appear in logs (an internal, non-reversible user ID is often sufficient) versus what's convenient but unnecessarily risky to retain.


11. Performance Considerations

Structured logging's overhead is generally negligible, with specific exceptions

// ✅ Efficient: the string formatting only happens if Debug level is actually enabled
logger.LogDebug("Processing item {ItemId} with payload {@Payload}", itemId, payload);
Enter fullscreen mode Exit fullscreen mode

.NET's ILogger (and Serilog underneath it) is specifically designed so that if a log call's level isn't currently enabled, the expensive parts (argument evaluation, especially for destructured @ objects) are skipped entirely, not computed and then discarded — this "check the level first" optimization is built in, meaning a LogDebug call in a hot path is genuinely cheap when Debug isn't enabled, contrary to a common assumption that logging calls always carry meaningful overhead regardless of whether they're actually emitted.

Where overhead genuinely matters

// A hot loop calling a moderately expensive destructuring operation MANY times per second
foreach (var item in millionItemBatch)
{
    logger.LogInformation("Processing {@Item}", item); // ❌ potentially expensive at this volume, even if the level check is cheap
}
Enter fullscreen mode Exit fullscreen mode

For genuinely hot paths processing very high volumes, even the reduced overhead of structured logging (allocating property dictionaries, serializing destructured objects) can add up — the standard mitigations are the same ones covered in this series' OpenTelemetry guide's sampling discussion: log at a coarser granularity in hot loops (a summary after the batch, not one line per item), or apply explicit sampling to genuinely high-frequency log statements.

Asynchronous sinks avoid blocking the calling thread

.WriteTo.Async(a => a.Seq("http://localhost:5341"))
Enter fullscreen mode Exit fullscreen mode

Writing to a network-based sink (Seq, Elasticsearch) synchronously on every log call would add real latency to whatever code path is doing the logging — wrapping sinks in Serilog's async sink wrapper buffers log entries and writes them on a background thread, keeping the calling code's logging statements fast regardless of the destination sink's own latency characteristics.


12. Common Pitfalls

Pitfall Why it hurts Better approach
String interpolation instead of message templates Destroys the structured data before it's ever logged Always use {PropertyName} placeholders with separate arguments
Mismatched placeholder order vs. argument order Silently mislabels properties with the wrong values Keep template placeholder order and argument order aligned; review carefully
Logging routine events at Warning/Error Trains the team to ignore those levels since they fire constantly Reserve Warning/Error for genuinely unexpected or actionable conditions
@-destructuring an object without checking what it contains Risks logging secrets or PII buried in an object's properties Review destructured objects for sensitive fields; log specific properties instead where needed
No enrichment for trace context Logs and traces remain two disconnected systems, missing OpenTelemetry's correlation payoff Add a span/trace enricher so every log entry carries TraceId/SpanId automatically
Logging at very high volume in hot loops with no sampling Real performance and storage cost at scale Log summaries rather than per-item detail in hot paths; sample where needed
Treating structured logging as "just add JSON formatting" Misses the actual point — properties need to be genuine, named, typed fields, not a JSON blob wrapping an interpolated string Use message templates with named placeholders from the start, not post-hoc JSON wrapping
No log level configuration per environment Production either drowns in Debug-level noise or is missing detail needed for troubleshooting Configure environment-specific minimum levels, per this series' ASP.NET Core configuration guidance

Quick Reference Table

Concept Purpose
Message template A fixed, human-readable string with named {Property} placeholders
ILogger<T> .NET's built-in, structured-logging-capable logging abstraction
Serilog The dominant third-party provider adding rich sinks, enrichment, and destructuring
Log level A deliberate signal of severity/actionability, not an afterthought
Scope / LogContext Attaches shared context to every log call within a block, without repetition
Enricher Automatically attaches contextual properties (trace ID, tenant, machine name) to every log entry
Sink A destination structured logs are written to (console, file, Seq, OTLP, etc.)
@ destructuring Captures an entire object's structure — use deliberately, watch for sensitive fields
Structured query Filtering/aggregating logs by genuine typed properties, not substring matching

Conclusion

Structured logging's value isn't really about the output format (JSON vs. plain text) — it's about treating every log entry as genuine, typed, queryable data from the moment it's written, via message templates with named placeholders, rather than an afterthought free-text string that happens to be JSON-wrapped. That discipline is what turns "how many payment failures did customer 42 have last week" from a fragile regex exercise into a reliable aggregation query, and it's what makes the trace-log correlation covered in this series' OpenTelemetry guide actually work in practice.

The concrete path in .NET is consistent and well-trodden: use ILogger<T>'s message template syntax everywhere (never string interpolation for log messages), adopt Serilog for the richer sink and enrichment ecosystem once basic console logging isn't enough, enrich every log entry with trace context to bridge into distributed tracing, apply the same secret- and PII-handling discipline covered throughout this series' security guides, and route logs through the same OpenTelemetry Collector pipeline as traces and metrics wherever that unified observability approach has already been adopted. Get those habits right, and logs stop being a wall of text someone greps through during an incident, and start being one of the most reliable, queryable sources of truth for understanding what a system is actually doing.


Found this useful? Feel free to star the repo, open an issue with corrections, or share the aggregation query that would have been impossible without structured logs.

Top comments (0)