Structured Logging
Structured logging saves each event with separate fields, not only as a sentence. With this, you can search the logs like a database. With the right log level and a shared ID like the Trace Id, we find the path of one request across several services.
Author: bezzad
The problem: we have logs, but no answers
A shop customer calls: “Money was taken from my account, but the order was not placed.” We have thousands of log lines. Each line looks something like this:
2026-10-06 10:14:03 Order 1042 paid by user 77, amount 1250000
Now answer these questions:
- Where are all the logs of order 1042 across four services?
- In the last hour, which payments were more than one million toman and failed?
- How many failed payments has user 77 had today?
With text logs, for each question we must write a text search pattern (Regex). If someone changes the log sentence a little, for example changes “paid by” to “paid from”, all searches and dashboards break silently.
The idea: a log is an event with fields, not a sentence
In Structured Logging, each log is an event with named fields. The sentence still exists so a person can read it. But the values are also saved separately so a machine can search on them.
Code: a Message Template, not a built string
In .NET we use the ILogger interface. The main point is that the log sentence is a template (Message Template). The name inside the curly braces becomes the field name:
public sealed class PaymentService(ILogger<PaymentService> logger)
{
public void MarkPaid(Order order)
{
// Good: OrderId, UserId and Amount become separate fields.
logger.LogInformation(
"Order {OrderId} paid by user {UserId}, amount {Amount}",
order.Id, order.UserId, order.Total);
// Bad: one plain string, no fields.
logger.LogInformation($"Order {order.Id} paid by user {order.UserId}");
}
}
The second line looks very much like the first line, but it has three problems:
- No fields are made. The string is built before it reaches the Logger. The Logger only sees a text.
- Each message is a new template. You cannot ask “how many times did this kind of message repeat?”. Because the text is different each time.
- Cost for no reason. The string is always built, even when this log level is turned off.
Log level: what do we write where?
Each log has a level. The level shows how important this event is. In Production, usually only the Information level and above are saved.
| Level | When? | Example in the shop |
|---|---|---|
| Trace and Debug | Details for troubleshooting in the development environment. | The value of each variable in the discount calculation. |
| Information | An important and normal event in the business. | The order was placed. The payment was done. |
| Warning | Something is unusual, but the work goes on. | The bank answered late, and the second try worked. |
| Error | A specific task failed. | The payment of order 1042 failed after three tries. |
| Critical | The whole app or a main part is in danger. | The connection to the database is fully down. |
Setting up Serilog
The Serilog library is very common in .NET. It works behind the same ILogger interface. So the app code does not depend on Serilog. This setup writes the logs as JSON to the console output. In Kubernetes, usually a collector tool picks up this output and sends it to the central tool.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSerilog(config => config
.ReadFrom.Configuration(builder.Configuration)
.Enrich.FromLogContext()
.WriteTo.Console(new CompactJsonFormatter()));
var app = builder.Build();
// One summary log line per HTTP request, with path, status code and duration.
app.UseSerilogRequestLogging();
We put the log level in the settings file so it can change without changing the code. We also reduce the noisy logs of ASP.NET Core itself:
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft.AspNetCore": "Warning"
}
}
}
}
Shared fields with a Scope
Some fields must be on all logs of one task. For example, the order ID on all payment logs. Instead of repeating it in each log, we open a Scope:
using (logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = order.Id }))
{
logger.LogInformation("Calling bank");
// ...
logger.LogWarning("Bank answered after {ElapsedMs} ms", elapsed);
}
Both logs inside this block have the OrderId field, without us writing it in the sentence.
A shared ID between services
One order passes through several services: orders, stock and payment. To find all its logs together, all logs must have a shared ID. We call it a Correlation Id.
Today the best choice is the standard W3C Trace Id. Because:
- .NET itself makes it. ASP.NET Core makes an Activity for each request, and it has a Trace Id.
- It moves between services automatically. The HttpClient class sends it to the next service in the traceparent header.
- It connects the log to the Trace. From one log line, we go directly to the full chart of the request path.
If you send logs with OpenTelemetry, the Trace Id is added to each log automatically. New versions of Serilog also record the current Trace Id with each event.
Logging in hot paths
In code that runs thousands of times per second, the cost of logging becomes important. .NET has a Source Generator that builds the logging code at compile time. This method is faster, and it also keeps the field names fixed in one place:
public static partial class PaymentLog
{
[LoggerMessage(Level = LogLevel.Information,
Message = "Order {OrderId} paid, amount {Amount}")]
public static partial void OrderPaid(this ILogger logger, int orderId, decimal amount);
}
// Usage:
logger.OrderPaid(order.Id, order.Total);
Important rules
- Never log sensitive data. Passwords, full card numbers, tokens, one-time codes. Many people see the logs, and they are kept for a long time.
- Always a template, never a built string. Not with the dollar sign, and not by adding strings together.
- The right level. A user error, like a wrong password, is not a system error. Do not record it as Error, or the alerts become meaningless.
- Business IDs in the log. Support does not know the Trace Id, but it knows the order number.
- Collect logs in one central place. A log on the disk of a Pod is lost when that Pod goes away.
- Control the volume. Many logs cost storage and make it hard to find the important thing. To count events, use a Metric, not a log.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| Building the string with the dollar sign | No fields are saved, and search is hard. | A template with curly braces, and the values as parameters. |
| Logging one error in all layers | One error is seen several times. | Only where the error is handled. |
| Only the error text, without the Exception object | The Stack Trace and the inner error are lost. | Give the Exception object to the log method. |
| Logging the whole request and response | Sensitive data in the log, and high cost. | Only the needed fields. |
| The Debug level turned on in Production | High volume and slowness. | The level in settings, turned on for a short time when needed. |
| No shared ID | The logs of one order in several services are not connected. | A Trace Id on all logs. |
Summary in six lines
- Structured logging means each log is an event with named fields.
- Always use a template with curly braces, not a built string.
- Choose the log level correctly. In Production, usually Information and above.
- With a Scope, add the shared fields of a task once.
- Collect all logs in one central place and put the Trace Id on them.
- Sensitive data must never go into a log.