Skip to content
Katabench
Try free
7 min read The Katabench team

Structured Logging in .NET: Write Logs You Can Query

Learn structured logging in .NET with ILogger message templates, named fields, scopes, correlation IDs, safe data handling, and query-first design.

Your checkout service fails three times. It writes three perfectly readable sentences. At 3 a.m., the useful question is not "can I read a sentence?" It is "how many checkout requests failed, for which dependency, and which trace contains the slowest one?"

If answering requires a regular expression, the log was formatted for a screen instead of modeled as data. Structured logging in .NET means emitting a stable event template plus named, typed fields that a logging provider can store and query. It does not require Serilog, a specific vendor, or even JSON on the console. The structure begins at the ILogger call.

A message template is not an interpolated string

These two calls can render the same sentence and preserve very different information:

// One finished string. The field names and types are gone.
logger.LogInformation(
    $"Checkout {orderId} returned {statusCode} in {elapsedMs} ms");

// One stable event plus three named values.
logger.LogInformation(
    "Checkout {OrderId} returned {StatusCode} in {ElapsedMs} ms",
    orderId,
    statusCode,
    elapsedMs);

The interpolated version evaluates first and hands the logger one string. A backend can index the timestamp, level, and category it adds, but recovering StatusCode means parsing prose. The message template version preserves OrderId, StatusCode, and ElapsedMs as properties. A text console may still print a sentence; a structured provider can store the same event as fields.

This is why "we output JSON" is not enough. Serializing an already-rendered sentence into a JSON property gives you structured transport around unstructured application data:

{
  "level": "Information",
  "message": "Checkout 8f31 returned 503 in 214 ms"
}

The useful version retains the event and its dimensions separately:

{
  "level": "Information",
  "messageTemplate": "Checkout {OrderId} returned {StatusCode} in {ElapsedMs} ms",
  "orderId": "8f31",
  "statusCode": 503,
  "elapsedMs": 214
}

For a quick local proof with the built-in provider, replace the default text console with the JSON console and include scopes:

var builder = WebApplication.CreateBuilder(args);

builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole(options =>
{
    options.IncludeScopes = true;
    options.TimestampFormat = "yyyy-MM-ddTHH:mm:ss.fffZ";
    options.UseUtcTimestamp = true;
});

That makes the preserved state visible without adding Serilog. Production systems usually export the same ILogger events through OpenTelemetry or another provider instead of treating container stdout as a database. Keep the application call provider-neutral so storage can change without rewriting every log statement.

Microsoft's current ILogger guidance defines the second argument as a message template whose placeholders are supplied by the remaining parameters. The provider decides where that structured event goes: JSON console, OpenTelemetry, Application Insights, Seq, Elasticsearch, or something else.

Incident question

How many /checkout requests failed?

Sentence

checkout failed for keyboard after 214ms

Invent a parser first

grep -E "failed.*after"

Named fields

route=/checkout status=500 duration_ms=214

Filter the data you already have

route == "/checkout" and status >= 500

Structured logging turns an incident question into a filter instead of a parsing project.

Design the query before the log call. If you cannot state the field names needed to answer the incident question, changing sinks will not make the event observable.

Choose fields from operational questions

Good fields describe the small set of dimensions engineers will filter, group, or join on. For an HTTP request, that might be the route template, status class, dependency name, operation name, and duration. For a business event, it might include an order identifier and outcome. Names should be consistent across services: OrderId in one component and order_id_value in another creates translation work during the incident.

Avoid dumping whole objects "for context." Object shapes change, serializers can expose fields you never intended to retain, and one innocent request model can contain email addresses, tokens, or payment data. Log the minimum fields needed for the question. Treat logs as durable exported data, not a private debug window.

The distinction between logs and metrics matters here. A unique order or correlation identifier is valuable on a log event because it finds one execution. The same identifier is dangerous as a Prometheus metric label because every value creates another time series. Use low-cardinality route, status, and dependency dimensions for metrics; use an identifier to connect detailed logs and traces after a metric has told you where to look.

Carry context with scopes and traces

Repeating CorrelationId and TenantId in every method call clutters signatures and makes omission likely. A logging scope attaches shared properties to every event inside a logical operation:

using var scope = logger.BeginScope(new Dictionary<string, object>
{
    ["CorrelationId"] = correlationId,
    ["TenantId"] = tenantId
});

logger.LogInformation(
    "Checkout {OrderId} started for {ItemCount} items",
    order.Id,
    order.Items.Count);

await inventory.ReserveAsync(order, cancellationToken);

logger.LogInformation(
    "Checkout {OrderId} completed with {Outcome}",
    order.Id,
    "Accepted");

Both events can now carry the same operation context if the provider includes scopes. In a distributed system, prefer the active Activity trace and span identifiers for call-graph identity, then use a business correlation ID only when it represents a genuinely different concept that must survive beyond one trace. Generating a fresh correlation ID in every service breaks the chain you were trying to create.

.NET's observability stack uses ILogger for logs, Meter for metrics, and ActivitySource for traces; OpenTelemetry collects and exports those framework signals rather than replacing their instrumentation APIs. The official .NET observability overview shows that relationship explicitly.

Keep secrets and personal data out

Structure makes sensitive data easier to search too. Never log authorization headers, access or refresh tokens, passwords, connection strings, full request bodies, or raw payment details. Be deliberate with email addresses, IP addresses, user-provided text, and URL query strings because retention and access rules can turn a debugging convenience into a privacy incident.

If a sensitive value is necessary for an approved diagnostic use case, classify and redact it before export. Microsoft's .NET data-redaction guidance supports erasing or consistently HMAC-redacting classified fields, but the safest field is still the one you never emit. Redaction is a control, not permission to log everything.

Also prevent log injection in text-oriented destinations. User input containing line breaks or terminal control characters should remain a property handled by the provider, not be concatenated into a handcrafted line format.

Optimize hot log paths after the shape is right

Ordinary LogInformation message templates are the right default. On a path that emits at very high frequency, source-generated logging avoids repeatedly parsing the template and can avoid boxing value types:

internal static partial class CheckoutLog
{
    [LoggerMessage(
        EventId = 2101,
        Level = LogLevel.Information,
        Message = "Checkout {OrderId} completed with {StatusCode}")]
    public static partial void Completed(
        ILogger logger,
        Guid orderId,
        int statusCode);
}

Stable EventId values also let a backend group one event kind even if its human-readable wording changes. Microsoft's high-performance logging guidance recommends source generation for this pattern. Measure before converting every call: an exquisitely optimized event with the wrong fields is still useless, while logging too much at Information can dominate ingestion cost regardless of the API used.

Prove the log with a query

A logging change is not done when the console looks attractive. Send known traffic, query the actual stored representation, and assert the answer:

  1. Generate three failed checkouts and one successful checkout.
  2. Filter Route == "/checkout" and StatusCode >= 500.
  3. Verify the result is three without parsing the rendered message.
  4. Select one event and follow its trace or correlation ID across the dependency call.
  5. Confirm no secret or personal field escaped into the payload.

That is the feedback loop in Katabench's observability work. The microservices course makes you replace a sentence with named fields, send known traffic, and count the failures from the running service; later lessons add RED metrics, bounded labels, and a real broken dependency. The Labs overview explains the disposable workspaces, and the Labs guide shows how each executable check proves the system behavior rather than merely scanning source code.

Structured logging is the detailed half of incident diagnosis: metrics tell you that failures rose, traces show where time went, and queryable events preserve the application facts needed to explain why. The same discipline belongs around reliable messaging: correlation, outcome, and attempt fields make an outbox publisher diagnosable without turning every delivery into a bespoke sentence.

Get new puzzles and .NET tips in your inbox

A short note when fresh kata land, plus the C# and performance tricks behind the grading. No spam, unsubscribe anytime.