C# & .NET (C# 14, .NET 10 LTS) Module 20 – Command-line apps, hosting, DI, configuration and telemetry
Structured logging, metrics and traces
Instrument a C# app with ILogger message templates, generated log methods, Meter instruments and ActivitySource spans, then export them with OpenTelemetry.
What you will learn
- Write structured logs with message templates and source-generated LoggerMessage methods
- Record counters, histograms and gauges with Meter, and keep their tags to a bounded set
- Trace an operation through its steps and across a call with ActivitySource and W3C trace context
- Export logs, metrics and traces with OpenTelemetry, and test them with in-memory collectors
Before you start
What you’ll learn · Before you start
What you will learn
- Write structured logs with message templates and source-generated LoggerMessage methods
- Record counters, histograms and gauges with Meter, and keep their tags to a bounded set
- Trace an operation through its steps and across a call with ActivitySource and W3C trace context
- Export logs, metrics and traces with OpenTelemetry, and test them with in-memory collectors
Before you start
On this page
A payment failed for one customer, and three questions follow: what happened to that order, how many orders fail each minute, and which step of the checkout was slow. A log line answers the first, a counter the second and a trace the third; in this lesson’s first run, three orders produced three log lines, six measurements and six spans.
You will write structured logging with message templates and generated methods, record metrics with Meter, follow
traces through steps and across a call with ActivitySource, and export all three with OpenTelemetry.
Logs, metrics and traces answer different questions
Structured logging records events: each line is a message template plus its values as named properties, such as
OrderId, so a log store can find one order without parsing text. Metrics are numbers aggregated over time, counts and
distributions such as orders by status, cheap to keep at any traffic. Traces follow one operation through its steps:
each step is a span (tracing): One timed step of a traced operation, such as a payment call, with a name, a start, a duration, tags, a status and a parent. In .NET a span is an Activity, started from an ActivitySource. with a duration, tags and a parent. signals.cs processes three orders,
and two listeners keep what an exporter would receive:
#:package Microsoft.Extensions.Hosting@10.0.12
#:property PublishAot=false
// Three orders, three signals: a structured log line per order, two metric instruments, and a trace of each order
// with a child for its payment. A MeterListener and an ActivityListener in the same program keep what an exporter
// would receive, and print it once the logger has written its lines.
using System.Diagnostics;
using System.Diagnostics.Metrics;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
List<string> measurements = [], spans = [];
using MeterListener meters = new() { InstrumentPublished = (instrument, l) => { if (instrument.Meter.Name == "Shop.Orders") l.EnableMeasurementEvents(instrument); } };
meters.SetMeasurementEventCallback<long>((i, value, tags, _) => measurements.Add($"{i.Name} {value} {Tags(tags)}"));
meters.SetMeasurementEventCallback<double>((i, value, tags, _) => measurements.Add($"{i.Name} {value} {Tags(tags)}"));
meters.Start();
using ActivityListener tracer = new()
{
ShouldListenTo = source => source.Name == "Shop.Orders",
Sample = (ref ActivityCreationOptions<ActivityContext> _) => ActivitySamplingResult.AllDataAndRecorded,
ActivityStopped = a => spans.Add($"{(a.Parent is null ? "" : " ")}{a.DisplayName} {string.Join(", ", a.Tags.Select(t => $"{t.Key}={t.Value}"))} status={a.Status}"),
};
ActivitySource.AddActivityListener(tracer);
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddJsonConsole();
builder.Services.AddSingleton<OrderProcessor>();
using (IHost host = builder.Build())
{
OrderProcessor processor = host.Services.GetRequiredService<OrderProcessor>();
processor.Process(new Order("A-1001", 499m, "card"));
processor.Process(new Order("A-1002", 1250m, "wallet"));
processor.Process(new Order("A-1003", 80m, "card", Declined: true));
} // disposing the host flushes the console logger, so its lines come first
Console.WriteLine("metrics:");
measurements.ForEach(m => Console.WriteLine($" {m}"));
Console.WriteLine("traces:");
spans.ForEach(s => Console.WriteLine($" {s}"));
static string Tags(ReadOnlySpan<KeyValuePair<string, object?>> tags)
{
List<string> parts = [];
foreach (var tag in tags) parts.Add($"{tag.Key}={tag.Value}");
return parts.Count == 0 ? "" : "{" + string.Join(", ", parts) + "}";
}
record Order(string Id, decimal Amount, string Method, bool Declined = false);
sealed class OrderProcessor(ILogger<OrderProcessor> logger)
{
static readonly ActivitySource Source = new("Shop.Orders");
static readonly Meter Meter = new("Shop.Orders");
static readonly Counter<long> Processed = Meter.CreateCounter<long>("shop.orders.processed", "{order}");
static readonly Histogram<double> Amounts = Meter.CreateHistogram<double>("shop.orders.amount", "INR");
public void Process(Order order)
{
using Activity? activity = Source.StartActivity("ProcessOrder");
activity?.SetTag("order.id", order.Id);
using (Activity? payment = Source.StartActivity("ChargePayment"))
{
payment?.SetTag("payment.method", order.Method);
if (order.Declined)
payment?.SetStatus(ActivityStatusCode.Error, "declined");
}
string status = order.Declined ? "declined" : "paid";
activity?.SetStatus(order.Declined ? ActivityStatusCode.Error : ActivityStatusCode.Ok);
logger.LogInformation("Order {OrderId} {Status}: {Amount} by {Method}", order.Id, status, order.Amount, order.Method);
Processed.Add(1, new KeyValuePair<string, object?>("status", status));
Amounts.Record((double)order.Amount);
}
} from session import run
# network: true. signals.cs names Microsoft.Extensions.Hosting 10.0.12 in its #:package line, and dotnet run
# restores it from nuget.org into the session's own, empty home folder.
run("""
cd signals
dotnet run signals.cs
""", network=True) Output
$ cd signals
$ dotnet run signals.cs
{"EventId":0,"LogLevel":"Information","Category":"OrderProcessor","Message":"Order A-1001 paid: 499 by card","State":{"OrderId":"A-1001","Status":"paid","Amount":499,"Method":"card","{OriginalFormat}":"Order {OrderId} {Status}: {Amount} by {Method}"}}
{"EventId":0,"LogLevel":"Information","Category":"OrderProcessor","Message":"Order A-1002 paid: 1250 by wallet","State":{"OrderId":"A-1002","Status":"paid","Amount":1250,"Method":"wallet","{OriginalFormat}":"Order {OrderId} {Status}: {Amount} by {Method}"}}
{"EventId":0,"LogLevel":"Information","Category":"OrderProcessor","Message":"Order A-1003 declined: 80 by card","State":{"OrderId":"A-1003","Status":"declined","Amount":80,"Method":"card","{OriginalFormat}":"Order {OrderId} {Status}: {Amount} by {Method}"}}
metrics:
shop.orders.processed 1 {status=paid}
shop.orders.amount 499
shop.orders.processed 1 {status=paid}
shop.orders.amount 1250
shop.orders.processed 1 {status=declined}
shop.orders.amount 80
traces:
ChargePayment payment.method=card status=Unset
ProcessOrder order.id=A-1001 status=Ok
ChargePayment payment.method=wallet status=Unset
ProcessOrder order.id=A-1002 status=Ok
ChargePayment payment.method=card status=Error
ProcessOrder order.id=A-1003 status=Error
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 signals_session.py
Each JSON line kept its values in State, beside the template. The counter received one measurement per order with a
status tag, and the histogram one amount; a metrics back end keeps only the totals. The spans arrived child first,
and the declined order’s ended with status Error. The instrumentation APIs are .NET’s own: Meter is in
System.Diagnostics.Metrics and ActivitySource in System.Diagnostics, both part of the runtime, and ILogger is in the
Microsoft.Extensions.Logging libraries, so code records telemetry without naming the tool that will collect it. Step
through one order:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The order starts. Process(A-1001) starts the ProcessOrder span, then a ChargePayment span inside it for the card payment.
- The child span ends. ChargePayment ends first, so the listener receives it before its parent. It carries the trace that ProcessOrder started.
- One log line, with its values. The logger writes one event. The JSON line keeps OrderId, Status, Amount and Method as named values next to the template, so a log store can find this order without reading text.
- Two measurements. The counter adds 1 with the tag status=paid, and the histogram records the amount, 499. A metrics back end keeps only running totals and distributions, not the order.
- The parent span ends. Process returns, ProcessOrder ends with status Ok, and the listener receives it, the second span of this trace.
- Three orders later. After all three orders the back ends hold three log lines, two counts by status and one distribution of amounts, and three traces with two spans each.
| Signal | Answers | Kept as | Grows with |
|---|---|---|---|
| Log | what happened to this order | one record per event | every event |
| Metric | how many, how fast | totals per tag set | distinct tag values |
| Trace | where the time went | spans in a tree | sampled operations |
A message template keeps placeholders such as {OrderId} instead of building a string. The [LoggerMessage] source
generator writes a logging method that skips all work when its level is off. A counter adds up, an up-down counter also
subtracts, a histogram records a distribution, and an observable gauge reports a value when read. W3C trace context
carries a trace from one service to the next in the traceparent header. OpenTelemetry collects all three signals and
exports them, and in-memory collectors let a test check what the code emitted.
Checkpoint
Check: Logs, metrics and traces
Message templates keep the values; interpolation throws them away Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Metrics aggregate, and every distinct tag set is a series
- Traces follow one operation, across steps and services
- OpenTelemetry exports all three signals the same way
- Practise: instrument an order processor
- Interview questions
- An engineer finds customers' phone numbers and one-time passwords in the production logs of your payments service. How did they most likely get there, how do you stop it, and how do you make sure it cannot quietly return?
- Traces of a checkout stop at the payment service: its spans start new traces instead of joining the caller's, so no trace shows the whole request. Payments receive work through a message queue. Why does it happen, and how do you fix and verify propagation?
- Key takeaways
1 diagram · 9 runnable examples (C#, PL, Python) · 2 interview answers · about 24 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Terms in this lesson
- message template
- The constant text of a log call with named placeholders, such as Order {OrderId} paid {Amount}. The logger keeps each argument as a named property, so logs can be searched by value.
- span (tracing)
- One timed step of a traced operation, such as a payment call, with a name, a start, a duration, tags, a status and a parent. In .NET a span is an Activity, started from an ActivitySource.
- trace context
- The ids that tie spans in different processes to one trace: the trace id, the parent span id and flags. The W3C Trace Context standard sends them in the traceparent header, as 00-traceid-spanid-flags.
References
- Logging in C# and .NET (Microsoft Learn)
- Compile-time logging source generation (Microsoft Learn)
- Creating metrics (Microsoft Learn)
- Add distributed tracing instrumentation (Microsoft Learn)
- .NET observability with OpenTelemetry (Microsoft Learn)
- Breaking change: ActivitySource.CreateActivity and ActivitySource.StartActivity behavior changes (Microsoft Learn)
- Breaking change: Default trace context propagator updated to W3C standard (Microsoft Learn)
- Trace Context (W3C Recommendation) (World Wide Web Consortium (W3C))
- OpenTelemetry .NET (OpenTelemetry (Cloud Native Computing Foundation))
- CA2254: Template should be a static expression (Microsoft Learn)
- Logging Cheat Sheet (OWASP Cheat Sheet Series)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress