Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free intro, Pro: 3 of 15 minutes free
Free: Logs, metrics and traces answer different questions. With a pass: the other 7 sections and 2 interview answers, about 12 minutes.

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:

signals/signals.cs C# · signals/signals.cs
#: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);
    }
}
Three orders, three log lines, six measurements, six spans Python · signals_session.py
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:

Interactive · step by step One order, three signals Step through order A-1001 and see what each API emits and what a back end keeps of it.
One order, three signals, step 6 of 6. Showing ILogger, Log store, Meter, Metrics back end, ActivitySource, Trace back end.ILoggerone eventLog storesearch by OrderIdMetercounter and histogramMetrics back endtotals over timeActivitySourceone span per stepTrace back endsteps in one trace

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The order starts. Process(A-1001) starts the ProcessOrder span, then a ChargePayment span inside it for the card payment.
  2. The child span ends. ChargePayment ends first, so the listener receives it before its parent. It carries the trace that ProcessOrder started.
  3. 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.
  4. 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.
  5. The parent span ends. Process returns, ProcessOrder ends with status Ok, and the listener receives it, the second span of this trace.
  6. 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

  1. Question 1 of 2 You need the slowest 1 % of checkout times over the last hour. Which signal answers it most cheaply?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: A metric, a histogram of checkout durations

    Metrics answer how many and how fast, aggregated over time. A histogram of durations gives the tail, p95 or p99, at the same cost whether there were a hundred checkouts or a million.

  2. Question 2 of 2 In the JSON lines of signals.cs, where did the value of OrderId appear, apart from the message text?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: In State, as a named property next to the template

    Each line held "{OriginalFormat}": "Order {OrderId} {Status}: {Amount} by {Method}" and each value under its own name, which is what makes the line searchable by OrderId.

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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.