Getting Started With OpenTelemetry in .NET

Getting Started With OpenTelemetry in .NET

7 min read··Updated ·

aspnetcoredotnetobservability

OpenTelemetry is the vendor-neutral standard for collecting traces, metrics, and logs: you instrument once and export to any backend over OTLP. In .NET, a few NuGet packages and one AddOpenTelemetry() call in Program.cs get every request traced end to end, with metrics and logs correlated to it.

A request hits your API, fans out to two downstream services, and fails somewhere in the middle. Without distributed tracing, finding out where means grepping logs on three machines and matching timestamps by hand. Here's how to set it up in .NET, from the first NuGet package to a production-shaped collector pipeline.

What Is OpenTelemetry?

OpenTelemetry (OTel) is a vendor-neutral standard for collecting traces, metrics, and logs from your application. Instead of locking into a specific vendor, you instrument once and export to any backend - Jaeger, Prometheus, Grafana, Datadog, or Azure Monitor.

The three pillars of observability:

  • Traces - follow a request across services
  • Metrics - count things, measure durations
  • Logs - structured diagnostic messages

.NET has a unique advantage here: OpenTelemetry's tracing API is the built-in Activity API, and metrics build on System.Diagnostics.Metrics. You're not bolting on a foreign SDK; you're exporting what the runtime and ASP.NET Core already produce.

Setting Up Tracing

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.SqlClient
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol

Configure in Program.cs:

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource =>
        resource.AddService(
            serviceName: "orders-api",
            serviceVersion: "1.0.0"))
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddSqlClientInstrumentation(options =>
            {
                options.SetDbStatementForText = true;
                options.RecordException = true;
            })
            .AddOtlpExporter(options =>
            {
                options.Endpoint = new Uri("http://localhost:4317");
            });
    });

This automatically traces:

  • Incoming HTTP requests (ASP.NET Core)
  • Outgoing HTTP requests (HttpClient)
  • Database queries (SqlClient)

Trace context propagates between services automatically via the W3C traceparent header, so a request that crosses three services still shows up as one trace. For a deeper conceptual walkthrough, see my introduction to distributed tracing.

You can also configure the exporter endpoint without code, using standard environment variables:

OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_SERVICE_NAME=orders-api

These are respected by the OTLP exporter out of the box, which makes per-environment configuration trivial in containers.

Viewing Traces in Jaeger

Run Jaeger locally:

docker run -d --name jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  jaegertracing/all-in-one:latest

Open http://localhost:16686 to see traces. Each trace shows the full journey of a request - from the API endpoint through HTTP calls to database queries.

An even faster option for local development: the standalone Aspire dashboard is a single container that displays traces, metrics, and logs from any OTLP source - no Jaeger, Prometheus, or Grafana required.

Custom Spans

Spans nest inside a single trace: the top-level operation becomes the root span, and each StartActivity call opens a child span underneath it. For the naming, lifecycle, and sampling details that make manual instrumentation reliable, see custom spans with ActivitySource.

A trace for POST /orders with a PlaceOrder span that has ValidateOrder and SaveOrder child spans, and a SQL INSERT span nested under SaveOrder

Add custom instrumentation for business operations:

public class OrderService
{
    private static readonly ActivitySource ActivitySource =
        new("OrdersApi.OrderService");

    public async Task<Order> PlaceOrderAsync(PlaceOrderCommand command)
    {
        using var activity = ActivitySource.StartActivity(
            "PlaceOrder",
            ActivityKind.Internal);

        activity?.SetTag("order.customer_id", command.CustomerId);
        activity?.SetTag("order.item_count", command.Items.Count);

        var order = Order.Create(command.CustomerId, command.Items);

        using (var validationActivity = ActivitySource.StartActivity(
            "ValidateOrder"))
        {
            order.Validate();
            validationActivity?.SetTag("order.valid", true);
        }

        using (var dbActivity = ActivitySource.StartActivity(
            "SaveOrder"))
        {
            _db.Orders.Add(order);
            await _db.SaveChangesAsync();
            dbActivity?.SetTag("order.id", order.Id);
        }

        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("order.total", order.TotalAmount);

        return order;
    }
}

Register the custom activity source. AddOpenTelemetry() calls are additive, so this extends the tracing setup from earlier:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing => tracing.AddSource("OrdersApi.OrderService"));

Setting Up Metrics

dotnet add package OpenTelemetry.Instrumentation.Runtime
dotnet add package OpenTelemetry.Instrumentation.Process
builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddRuntimeInstrumentation()
            .AddProcessInstrumentation()
            .AddOtlpExporter(options =>
            {
                options.Endpoint = new Uri("http://localhost:4317");
            });
    });

Built-in metrics include request duration, request count, active connections, GC collections, and thread pool size.

Custom Metrics

I cover metrics in much more depth (types, cardinality, histogram buckets) in application metrics with OpenTelemetry. Here's the short version:

public class OrderMetrics
{
    private readonly Counter<long> _ordersPlaced;
    private readonly Counter<long> _ordersCancelled;
    private readonly Histogram<double> _orderProcessingDuration;
    private readonly UpDownCounter<long> _activeOrders;

    public OrderMetrics(IMeterFactory meterFactory)
    {
        var meter = meterFactory.Create("OrdersApi");

        _ordersPlaced = meter.CreateCounter<long>(
            "orders.placed",
            description: "Total number of orders placed");

        _ordersCancelled = meter.CreateCounter<long>(
            "orders.cancelled",
            description: "Total number of orders cancelled");

        _orderProcessingDuration = meter.CreateHistogram<double>(
            "orders.processing.duration",
            unit: "ms",
            description: "Order processing duration in milliseconds");

        _activeOrders = meter.CreateUpDownCounter<long>(
            "orders.active",
            description: "Number of active (non-completed) orders");
    }

    public void OrderPlaced(decimal amount)
    {
        _ordersPlaced.Add(1,
            new KeyValuePair<string, object?>("currency", "USD"));
        _activeOrders.Add(1);
    }

    public void OrderCancelled()
    {
        _ordersCancelled.Add(1);
        _activeOrders.Add(-1);
    }

    public void RecordProcessingDuration(double milliseconds)
    {
        _orderProcessingDuration.Record(milliseconds);
    }
}

Register and use:

builder.Services.AddSingleton<OrderMetrics>();

builder.Services.AddOpenTelemetry()
    .WithMetrics(metrics => metrics.AddMeter("OrdersApi"));

Structured Logging With OpenTelemetry

builder.Logging.AddOpenTelemetry(options =>
{
    options.IncludeScopes = true;
    options.IncludeFormattedMessage = true;
    options.AddOtlpExporter(otlp =>
    {
        otlp.Endpoint = new Uri("http://localhost:4317");
    });
});

Logs are automatically correlated with traces through the trace ID:

_logger.LogInformation(
    "Order {OrderId} placed by customer {CustomerId} for {Amount}",
    order.Id,
    order.CustomerId,
    order.TotalAmount);

The log entry includes the trace ID, making it easy to find all logs related to a specific request.

Enriching Spans

Add contextual information to spans:

// Middleware to enrich all spans
public class TelemetryEnrichmentMiddleware
{
    private readonly RequestDelegate _next;

    public TelemetryEnrichmentMiddleware(RequestDelegate next)
    {
        _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var activity = Activity.Current;

        activity?.SetTag("user.id",
            context.User.FindFirstValue(ClaimTypes.NameIdentifier));
        activity?.SetTag("tenant.id",
            context.Request.Headers["X-Tenant-Id"].FirstOrDefault());

        await _next(context);

        activity?.SetTag("http.response.status_code",
            context.Response.StatusCode);
    }
}

Error Recording

try
{
    await ProcessOrderAsync(command);
}
catch (Exception ex)
{
    Activity.Current?.SetStatus(ActivityStatusCode.Error, ex.Message);
    Activity.Current?.AddException(ex);
    throw;
}

Errors appear in Jaeger as red spans with the full exception details. AddException is built into .NET 9; on older targets, use the RecordException extension method from the OpenTelemetry API package.

Sampling

Tracing every request is fine in development and often too expensive in production. Sampling keeps a representative fraction:

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
        tracing.SetSampler(
            new ParentBasedSampler(
                new TraceIdRatioBasedSampler(0.10)))); // keep 10%

ParentBasedSampler is the important part: it respects the sampling decision of the calling service, so a trace is either captured across all services or not at all. Without it, you get fragments of traces that are worse than useless.

Start at 100% while traffic is low, and dial down as volume grows. Errors are rare and valuable, so if your backend supports tail-based sampling (deciding after the trace completes), prefer keeping all error traces.

Docker Compose for the Full Stack

The application sends everything to the collector on port 4317, and the collector fans out to Jaeger and exposes metrics for Prometheus to scrape:

A .NET app exports over OTLP to the OpenTelemetry Collector, which forwards traces to Jaeger and metrics to Prometheus, with Grafana reading from both for dashboards
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686" # UI

  prometheus:
    image: prom/prometheus:latest
    ports:
      - "9090:9090"
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml

  grafana:
    image: grafana/grafana:latest
    ports:
      - "3000:3000"

  otel-collector:
    image: otel/opentelemetry-collector-contrib:latest
    ports:
      - "4317:4317" # OTLP gRPC (your app points here)
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    command: ["--config=/etc/otel-collector-config.yaml"]

The compose file mounts two configs. Here's otel-collector-config.yaml, which receives OTLP from your app and fans out traces to Jaeger and metrics to a Prometheus endpoint:

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317

exporters:
  otlp/jaeger:
    endpoint: jaeger:4317
    tls:
      insecure: true
  prometheus:
    endpoint: 0.0.0.0:8889

service:
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp/jaeger]
    metrics:
      receivers: [otlp]
      exporters: [prometheus]

And prometheus.yml, which scrapes the metrics the collector exposes on port 8889:

scrape_configs:
  - job_name: 'otel-collector'
    scrape_interval: 15s
    static_configs:
      - targets: ['otel-collector:8889']

Only the collector publishes port 4317 to the host; it forwards traces to Jaeger over the internal Docker network, and Prometheus scrapes it internally too. This is the production-shaped setup: your app only ever knows about one OTLP endpoint, and you reconfigure backends in the collector.

For wiring the Grafana dashboards on top of this stack, see monitoring .NET applications with OpenTelemetry and Grafana.

Summary

OpenTelemetry in .NET:

  1. Traces - automatic instrumentation for ASP.NET Core, HttpClient, and SQL
  2. Custom spans - use ActivitySource for business operations
  3. Metrics - counters, histograms, and gauges for business KPIs
  4. Logs - automatically correlated with traces via trace ID
  5. Vendor-neutral - export to Jaeger, Prometheus, Datadog, or any OTLP backend
  6. Enrich spans with user context, tenant IDs, and custom tags
  7. Sample in production - ParentBased + ratio sampling keeps traces complete

OpenTelemetry is the future of observability. Instrument once, export anywhere.


Frequently Asked Questions

What is OpenTelemetry in .NET?

OpenTelemetry is a vendor-neutral standard for collecting traces, metrics, and logs. In .NET it builds on Activity and System.Diagnostics.Metrics, so you instrument once and export to any backend like Jaeger, Prometheus, Grafana, or Azure Monitor over OTLP.

How do you add distributed tracing to an ASP.NET Core app?

Add the OpenTelemetry.Extensions.Hosting package, call AddOpenTelemetry().WithTracing, and enable the ASP.NET Core, HttpClient, and SQL instrumentation packages. Incoming requests, outgoing calls, and database queries are then traced automatically.

What is the difference between a trace and a span?

A trace is the full journey of one request through the system. A span is a single timed operation within it, like an HTTP call or database query. Spans nest to form the trace tree. In .NET, a span is represented by the Activity class.

Do I need the OpenTelemetry Collector?

Not to get started; your app can export straight to a backend over OTLP. A collector becomes useful in production for buffering, sampling, redaction, and fanning out telemetry to multiple backends without changing application code.

Does OpenTelemetry tracing slow down my application?

The overhead is small for typical workloads, but not zero. In high-throughput services, use sampling (for example ParentBased with TraceIdRatioBased) to record a fraction of traces while keeping every trace complete end to end.

Loading comments...

Whenever you're ready, there are 4 ways I can help you:

  1. Pragmatic Clean Architecture: Join 5,000+ students in this comprehensive course that will teach you the system I use to ship production-ready applications using Clean Architecture. Learn how to apply the best practices of modern software architecture.
  2. Modular Monolith Architecture: Join 2,800+ engineers in this in-depth course that will transform the way you build modern systems. You will learn the best practices for applying the Modular Monolith architecture in a real-world scenario.
  3. Pragmatic REST APIs: Join 1,900+ students in this course that will teach you how to build production-ready REST APIs using the latest ASP.NET Core features and best practices. It includes a fully functional UI application that we'll integrate with the REST API.
  4. Patreon Community: Join a community of 5,000+ engineers and software architects. You will also unlock access to the source code I use in my YouTube videos, early access to future videos, and exclusive discounts for my courses.

The .NET Weekly

Become a Better .NET Software Engineer

Join 66,000+ engineers who are improving their skills every Saturday morning.