Structured logging that you can actually query

ยท 1 min read

A log message built with string interpolation is just text. A message template turns every value into a field you can filter and chart in Application Insights.

When an integration fails at 3 a.m., logs are how you find out what happened. Whether you can find it quickly depends on one small habit: how you write the log call.

Interpolation hides your data

logger.LogInformation($"Order {order.Id} sent to billing in {elapsed} ms");

This produces a perfectly readable line of text. In Application Insights it's also just text. To find every message for one order, you're searching strings.

Message templates keep it structured

logger.LogInformation("Order {OrderId} sent to billing in {ElapsedMs} ms", order.Id, elapsed);

The output reads the same, but OrderId and ElapsedMs are now stored as separate properties. In Application Insights they land in customDimensions, and you can query them directly:

traces
| where customDimensions.OrderId == "4815"
| order by timestamp asc

or chart them:

traces
| where message startswith "Order"
| summarize avg(todouble(customDimensions.ElapsedMs)) by bin(timestamp, 1h)

The analyzer rule CA2254 warns when a log message isn't a constant template, so you can catch interpolation in code review automatically.

Add context once with scopes

Instead of repeating the order ID in every log call, wrap the work in a scope:

using (logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = order.Id }))
{
    logger.LogInformation("Validating order");
    logger.LogInformation("Sending to billing");
}

Every log entry inside the scope carries OrderId, including entries written by code that doesn't know about it.

Use the source generator on hot paths

For logging in frequently called code, LoggerMessage generates the logging method at compile time, avoiding boxing and repeated template parsing:

public static partial class Log
{
    [LoggerMessage(Level = LogLevel.Information, Message = "Order {OrderId} sent to billing in {ElapsedMs} ms")]
    public static partial void OrderSent(ILogger logger, string orderId, long elapsedMs);
}

Log.OrderSent(logger, order.Id, elapsed);

Check what's actually captured

By default, the Application Insights logger only sends Warning and above. If your Information logs are missing, set the level explicitly:

{
  "Logging": {
    "ApplicationInsights": {
      "LogLevel": { "Default": "Information" }
    }
  }
}

Be deliberate about it, because every log line costs ingestion.

Takeaway

Write log calls with message templates, never interpolation, so every value becomes a queryable field. Use scopes for context that applies to a whole operation, the LoggerMessage generator on hot paths, and check your log level so the entries you need actually reach Application Insights.