# Getting Started With OpenTelemetry in .NET

> OpenTelemetry is the standard for distributed tracing, metrics, and logging. Here is how to instrument your .NET application and export telemetry to Jaeger, Prometheus, and Seq.

Published: 2026-07-04. Last updated: 2026-07-14. Author: Milan Jovanović.

Canonical: https://milanjovanovic.tech/blog/opentelemetry-dotnet-guide

**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

```bash
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`:

```csharp
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**](https://milanjovanovic.tech/blog/introduction-to-distributed-tracing-with-opentelemetry-in-dotnet).

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

```bash
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:

```bash
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**](https://milanjovanovic.tech/blog/standalone-aspire-dashboard-setup-for-distributed-dotnet-applications) 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](https://milanjovanovic.tech/blogs/articles/opentelemetry-dotnet-guide/span-hierarchy.png)

Add custom instrumentation for business operations:

```csharp
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:

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

## Setting Up Metrics

```bash
dotnet add package OpenTelemetry.Instrumentation.Runtime
dotnet add package OpenTelemetry.Instrumentation.Process
```

```csharp
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:

```csharp
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:

```csharp
builder.Services.AddSingleton<OrderMetrics>();

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

## Structured Logging With OpenTelemetry

```csharp
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:

```csharp
_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:

```csharp
// 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

```csharp
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:

```csharp
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](https://milanjovanovic.tech/blogs/articles/opentelemetry-dotnet-guide/telemetry-pipeline.png)


```yaml
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:

```yaml
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:

```yaml
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**](https://milanjovanovic.tech/blog/monitoring-dotnet-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.
