Structured Logging in Azure Applications
Unstructured log lines like ERROR: payment failed for user john@example.com are nearly useless at scale. You cannot filter by user ID, aggregate error counts by payment method, or build meaningful charts from free-form strings. Structured logging — recording log entries as objects with named, typed fields — transforms your logs from a liability into an asset that KQL can interrogate with precision.
Why structure matters in Log Analytics
Azure Log Analytics stores your logs in tables. Each row is a log entry. Each column is a field. When your log entry arrives as a structured object, its fields map cleanly to columns. When it arrives as a plain string, everything goes into a single text field that requires expensive full-text search and regex parsing to extract meaning.
Compare these two approaches for an e-commerce order failure:
Unstructured:
ERROR 2026-03-19 14:23:01 Payment processing failed for order 9182 user 4421 amount 149.99 method visa error gateway_timeoutStructured:
{
"timestamp": "2026-03-19T14:23:01Z",
"level": "Error",
"message": "Payment processing failed",
"orderId": "9182",
"userId": "4421",
"amount": 149.99,
"paymentMethod": "visa",
"errorCode": "gateway_timeout",
"durationMs": 30004,
"correlationId": "a3f2e1d4-8b9c-4a2f-b1d3-e5f6a7b8c9d0"
}With the structured version, a KQL query can find all gateway timeouts above $100 for Visa payments in the last hour with two lines of code. With the unstructured version, you would need fragile regex parsing that breaks when the message format changes.
Designing a consistent log schema
A good log schema includes a small set of standard fields present on every entry, plus context-specific fields added per operation. Standard fields let you write reusable queries that work across all services. Context fields let you drill into specific operations.
| Field | Type | Purpose |
|---|---|---|
| timestamp | datetime | When the event occurred (UTC) |
| level | string | Severity: Debug, Info, Warning, Error, Critical |
| message | string | Human-readable description of the event |
| correlationId | string (GUID) | Ties all log entries from the same request together |
| service | string | Which microservice or component emitted this log |
| environment | string | production, staging, development |
| userId | string | Non-PII user identifier for user-scoped queries |
| durationMs | number | How long the operation took (when applicable) |
| errorCode | string | Machine-readable error classification |
Structured logging with Serilog and Application Insights
Serilog is the most widely used structured logging library for .NET applications. It supports message templates with named properties, and its Application Insights sink routes log entries directly to your workspace’s AppTraces table.
# Install Serilog packages for an ASP.NET Core application
dotnet add package Serilog.AspNetCore
dotnet add package Serilog.Sinks.ApplicationInsights
dotnet add package Microsoft.ApplicationInsights.AspNetCoreConfigure Serilog in your application startup:
// appsettings.json
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Enrich": ["FromLogContext", "WithMachineName", "WithEnvironmentName"],
"Properties": {
"Application": "OrderService",
"Environment": "Production"
}
},
"ApplicationInsights": {
"ConnectionString": "InstrumentationKey=your-key;IngestionEndpoint=..."
}
}Log context enrichment
The most valuable aspect of structured logging is enriching every log entry with the request context automatically. Using Serilog’s LogContext.PushProperty, you can push the correlation ID, user ID, and operation name once at the start of a request, and have them appear on every subsequent log entry within that request’s scope — without passing them as parameters to every method.
In an ASP.NET Core middleware:
# This is C# pseudocode showing the logging pattern concept
# Middleware adds correlationId and userId to every log entry in the request
using Serilog.Context;
# At the start of each request:
# using (LogContext.PushProperty("CorrelationId", correlationId))
# using (LogContext.PushProperty("UserId", userId))
# {
# await _next(context);
# }
# Individual log calls then just log what happened:
# _logger.LogInformation("Order {OrderId} placed for {Amount:C}", orderId, amount);
# _logger.LogError(ex, "Payment failed for order {OrderId} with error {ErrorCode}", orderId, errorCode);These properties flow into Application Insights as customDimensions on each AppTraces entry, making them queryable in KQL.
Querying structured logs in KQL
Custom properties from structured logging appear in the customDimensions column in Log Analytics. This is a JSON object. You access individual fields using the customDimensions[“fieldName”] or tostring(customDimensions.fieldName) syntax.
// Find all payment failures in the last hour by error code
AppTraces
| where TimeGenerated > ago(1h)
| where SeverityLevel >= 3 // 3 = Warning, 4 = Error
| where Message contains "Payment"
| extend
OrderId = tostring(customDimensions["orderId"]),
ErrorCode = tostring(customDimensions["errorCode"]),
PaymentMethod = tostring(customDimensions["paymentMethod"]),
DurationMs = todouble(customDimensions["durationMs"])
| summarize
FailureCount = count(),
AvgDuration = avg(DurationMs)
by ErrorCode, PaymentMethod
| order by FailureCount desc// Trace a specific user's journey through the system
AppTraces
| where TimeGenerated > ago(24h)
| where customDimensions["userId"] == "4421"
| project TimeGenerated, Message, SeverityLevel,
CorrelationId = tostring(customDimensions["correlationId"]),
Service = tostring(customDimensions["service"])
| order by TimeGenerated ascUse extend to extract customDimensions fields into named columns early in your query, before the where and summarize clauses. This makes the query more readable and often allows the query engine to optimize better.
Log level strategy for production
Logging too much is almost as problematic as logging too little. Every log entry costs money to ingest and store, and too many log entries create noise that slows down incident investigation. A practical production strategy:
- Debug / Verbose — Disabled in production. Enable temporarily via a feature flag or app setting for specific troubleshooting.
- Information — Key business events: order placed, user logged in, job completed. Keep this selective — not every function call, just meaningful milestones.
- Warning — Recoverable issues: retry attempted, fallback activated, deprecated API called, rate limit approaching.
- Error — Failed operations that affect a user or a specific request. Payment failed, database connection lost, file not found.
- Critical — Service is at risk: database unavailable, certificate expired, out of memory. These should always alert immediately.
Common mistakes
- Logging the same event at multiple levels. Logging “attempting payment” at Info and “payment succeeded” at Info and “payment failed” at Error is fine. Logging the same payment failure at Warning, Error, and then catching it again upstream and logging it at Critical creates triplicated noise during an incident.
- Using string interpolation instead of message templates.
_logger.LogError($“Order {orderId} failed”)loses theorderIdas a structured property — it just becomes part of the string._logger.LogError(“Order {OrderId} failed”, orderId)preservesOrderIdas a queryable field. - Logging PII. Email addresses, phone numbers, and full names should never appear in log entries. Use user IDs. During an incident, you can look up the user from the ID using your database — but you cannot undo a GDPR violation caused by years of logged PII.
- Not including a correlationId. Without a correlation ID, it is nearly impossible to reconstruct what happened during a specific request when hundreds of other requests are interleaved in the logs. Generate a GUID per request and propagate it through every log entry and every outbound call.
Summary
- Structured logs store entries as objects with named fields, making them queryable and aggregatable in KQL rather than requiring fragile text parsing.
- A consistent schema with fields like correlationId, service, userId, and errorCode enables powerful cross-service queries.
- Use Serilog with the Application Insights sink to emit structured logs from .NET applications; properties appear in
customDimensions. - Log context enrichment via middleware ensures correlation IDs and user context appear on every log entry without manual parameter passing.
- Never log PII; use opaque identifiers. Limit Debug/Verbose to troubleshooting windows, not permanent production configuration.
Frequently asked questions
What is the difference between structured logging and plain text logging?
Structured logging stores each log entry as a machine-readable object with named fields (timestamp, level, message, userId, orderId). Plain text logs are free-form strings. Structured logs can be filtered, grouped, and aggregated in Log Analytics far more effectively than unstructured text.
Does Application Insights support structured logging automatically?
Application Insights collects structured properties when you use the Application Insights SDK or a logging framework like Serilog with the Serilog.Sinks.ApplicationInsights sink. Custom properties appear in the customDimensions column in Log Analytics.
How do I avoid logging sensitive data?
Use destructuring with allowlists rather than blocklists. Define which properties are safe to log, rather than trying to redact sensitive ones. For PII, use opaque identifiers (user IDs) rather than email addresses or names in log entries.