BrandGhost
OpenTelemetry ASP.NET Core: Setup, Auto-Instrumentation, and Configuration

OpenTelemetry ASP.NET Core: Setup, Auto-Instrumentation, and Configuration

OpenTelemetry ASP.NET Core integration is the practical starting point for most .NET teams moving toward a proper observability setup. You get request tracing, metrics collection, and structured logging all flowing through a single standardized SDK -- without writing instrumentation code from scratch for every endpoint. One call to AddOpenTelemetry() in Program.cs, a few configuration choices, and you have production-grade telemetry wired in.

This guide covers the full setup from AddOpenTelemetry() all the way to sampler configuration, health check filtering, and OTLP export for production. The patterns apply to .NET 9 and .NET 10 and should give you a working foundation you can build on incrementally. Whether you're starting fresh or retrofitting observability into an existing application, the approach is the same.

If you're looking for the full picture, check out the OpenTelemetry in .NET: Complete Observability Guide.

Getting Started with OpenTelemetry ASP.NET Core

The central extension method for configuring OpenTelemetry ASP.NET Core is AddOpenTelemetry() on IServiceCollection. You call it once in Program.cs and chain signal-specific configuration off it. If you're not familiar with how ASP.NET Core's dependency injection and service registration works, that's a useful background before reading further.

The SDK covers three signals:

  • Tracing -- distributed trace spans representing operations within and across services
  • Metrics -- counters, histograms, and gauges collected at regular intervals
  • Logging -- structured log records with automatic trace context correlation

Each signal is optional and independently configured. A team just starting out might enable tracing first, add metrics in the next sprint, and layer in centralized logging once the pipeline is stable. The AddOpenTelemetry() design supports this incremental approach well -- you extend the chain rather than reconfigure from scratch.

OpenTelemetry registers as a set of hosted services, background exporters, and (for logging) a logging provider. It does not add middleware to your request pipeline, so there's no ordering concern relative to your existing middleware.

All Three Signals: A Complete OpenTelemetry ASP.NET Core Setup

The following example shows all three signals configured together with OTLP export. This is a solid starting point for most ASP.NET Core services.

using OpenTelemetry.Logs;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

// Add OpenTelemetry with all three signals
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(
            serviceName: builder.Configuration["Otel:ServiceName"] ?? "MyApp",
            serviceVersion: "1.0.0")
        .AddAttributes(new Dictionary<string, object>
        {
            ["deployment.environment"] =
                builder.Environment.EnvironmentName.ToLowerInvariant()
        }))
    .WithTracing(tracing => tracing
        .AddSource("MyApp.*")
        .AddAspNetCoreInstrumentation(options =>
        {
            options.Filter = ctx => ctx.Request.Path != "/health";
        })
        .AddOtlpExporter())
    .WithMetrics(metrics => metrics
        .AddMeter("MyApp.*")
        .AddAspNetCoreInstrumentation()
        .AddRuntimeInstrumentation()
        .AddOtlpExporter());

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
    logging.AddOtlpExporter();
});

This setup wires all three signals to the same OTLP endpoint, using the same resource metadata. The service name, version, and deployment environment are defined once and appear on every trace span, metric data point, and log record produced by the application. That consistency is what makes cross-signal correlation in backends like Grafana or Azure Monitor work well.

Configuring the Resource: Service Identity and Environment

The resource is the metadata block that tells your backend which service produced a piece of telemetry. It's shared across all three signals and configured through ConfigureResource(). Getting this right early matters because it determines how your data is organized in every backend you ship telemetry to.

The most important resource attributes are:

  • service.name -- identifies the service. Required, and should be stable and unique across your fleet.
  • service.version -- helps correlate telemetry with specific deployments. Especially useful when investigating regressions after a release.
  • deployment.environment -- distinguishes production, staging, and development traffic. Essential if you're using a shared observability backend.

Setting deployment.environment from builder.Environment.EnvironmentName is a practical choice because ASP.NET Core automatically resolves this from the ASPNETCORE_ENVIRONMENT environment variable. That means your telemetry is tagged correctly in every environment without any application code changes.

For deploying ASP.NET Core applications to Azure or Docker, you typically set ASPNETCORE_ENVIRONMENT to Production or Staging in your deployment configuration, and deployment.environment will reflect that automatically.

Tracing HTTP Requests with AddAspNetCoreInstrumentation

AddAspNetCoreInstrumentation() in the tracing pipeline automatically creates spans for every incoming HTTP request. This is the highest-value single line of code in a typical OpenTelemetry ASP.NET Core setup -- you get meaningful traces without writing any custom instrumentation.

The spans created by this instrumentation carry attributes including:

  • http.method -- GET, POST, PUT, DELETE, etc.
  • http.route -- the matched route template (e.g., /orders/{id}, not the actual URL)
  • http.status_code -- the HTTP response status
  • url.full -- the full URL (be careful with this in PII-sensitive environments)
  • Request duration as the span's timing

Error propagation is also automatic. A 5xx response marks the span status as Error. This means your error rate appears in tracing backends without any additional configuration, and you can set alerts against it immediately.

The route template in http.route rather than the full URL is an important detail. It means all requests to /orders/123 and /orders/456 aggregate under the same /orders/{id} operation in your trace visualizations. If you used the full URL, every unique order ID would appear as a distinct operation -- making your dashboards unreadable.

AddAspNetCoreInstrumentation() works with minimal APIs and controllers alike because it instruments at the HTTP layer, not the routing layer. The route template is determined after routing completes, but the span is started when the request arrives.

Configuring the OTLP Exporter from Environment Variables

For production, you'll typically configure the OTLP endpoint from environment variables or application configuration rather than hardcoding a URI. This enables different environments to ship telemetry to different collectors without application code changes.

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService("MyApp"))
    .WithTracing(tracing =>
    {
        tracing.AddAspNetCoreInstrumentation();

        var otlpEndpoint = builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"];

        if (!string.IsNullOrEmpty(otlpEndpoint))
        {
            tracing.AddOtlpExporter(otlp =>
            {
                otlp.Endpoint = new Uri(otlpEndpoint);
            });
        }
        else
        {
            // Local development -- use console exporter
            tracing.AddConsoleExporter();
        }
    });

OTEL_EXPORTER_OTLP_ENDPOINT is part of the OpenTelemetry specification for environment-based configuration. It's recognized across languages, collectors, and backends -- setting it in a Kubernetes pod spec, a Docker Compose service definition, or an Azure App Service application setting all work the same way.

A practical pattern for production is a sidecar OTel Collector container in your deployment. Your application sends telemetry to http://localhost:4317, the collector applies processing rules (batching, sampling, attribute scrubbing), and forwards to your final backend. The application doesn't need to know about backend-specific protocols or authentication -- the collector handles that.

For ASP.NET Core Middleware teams familiar with centralized configuration patterns, this approach will feel familiar. Externalize the destination, keep the application code consistent.

Collecting Metrics with WithMetrics()

The WithMetrics() extension configures the metrics pipeline. Like tracing, calling AddAspNetCoreInstrumentation() inside WithMetrics() gives you a useful set of built-in metrics immediately:

  • http.server.request.duration -- a histogram of HTTP request durations. This is the primary source for p50/p95/p99 latency dashboards.
  • http.server.active_requests -- the number of in-flight requests at any given moment.
  • Error rate breakdown by status code category.

AddRuntimeInstrumentation() adds .NET runtime metrics that are invaluable for diagnosing performance issues that aren't visible at the application level:

  • Thread pool queue depth and thread count
  • GC collection counts by generation
  • GC heap size
  • CPU time consumed

These runtime metrics are particularly useful when you're seeing latency spikes that don't correlate with request rate changes. GC pressure causing long pause times, or thread pool starvation causing request queuing, shows up clearly in these metrics when they might be invisible in your application-level dashboards.

For custom meters, registering AddMeter("MyApp.*") subscribes to any Meter instance whose name matches the pattern. If you create a Meter("MyApp.Orders") in your order processing code, its instruments are automatically picked up and exported without any additional registration.

Sampling Strategies for Production Cost Control

Not every request needs to be traced. At any meaningful scale, tracing 100% of requests creates substantial data volume and corresponding storage cost in your backend. Sampling is how you control what fraction of traces you actually export.

.WithTracing(tracing => tracing
    .AddAspNetCoreInstrumentation()
    .SetSampler(new ParentBasedSampler(
        new TraceIdRatioBasedSampler(
            double.TryParse(builder.Configuration["Otel:SampleRate"], out var sampleRate) ? sampleRate : 0.1))))
    .AddOtlpExporter())
// ParentBasedSampler respects the parent's sampling decision
// TraceIdRatioBasedSampler samples ~10% of new traces

The OpenTelemetry ASP.NET Core SDK ships four built-in samplers worth knowing:

AlwaysOnSampler -- every trace is exported. Appropriate for development and low-traffic internal services where cost is not a concern. When no sampler is configured, the SDK defaults to ParentBasedSampler(new AlwaysOnSampler()) -- which records every trace but respects the parent's sampling decision.

AlwaysOffSampler -- no traces are exported. Useful for disabling tracing in specific environments without removing instrumentation code from the application. Rarely used in practice but occasionally helpful for temporary cost reduction.

TraceIdRatioBasedSampler -- samples a deterministic percentage of traces based on the trace ID hash. A value of 0.1 samples approximately 10% of new traces. Because sampling is deterministic based on the trace ID, the same logical request will consistently be sampled (or not) each time, which matters for tail-based analysis.

ParentBasedSampler -- this is the sampler you'll use in most production services that are part of a distributed system. It delegates the sampling decision to the parent span's decision if one exists, and falls back to a root sampler for traces that don't have a parent. This ensures consistent sampling across all services in a call chain -- if your API gateway decides to sample a request, every downstream service processing that request will also sample it. Without ParentBasedSampler, you can end up with partial traces where some services sampled and others didn't, making the trace useless for analysis.

The combination of ParentBasedSampler wrapping TraceIdRatioBasedSampler is a practical default for most production APIs. Configure the sample rate from application settings so you can adjust per environment without redeployment. Start at 10%, monitor your backend storage costs, and tune from there.

Filtering Health Checks and Noisy Endpoints from Tracing

In any production ASP.NET Core application, you'll have health check endpoints that get polled every few seconds by load balancers, readiness probes, and container orchestrators. Tracing every one of those requests adds noise without adding diagnostic value.

.AddAspNetCoreInstrumentation(options =>
{
    options.Filter = httpContext =>
    {
        var path = httpContext.Request.Path.Value ?? string.Empty;

        // Skip health check and Prometheus scraping endpoints
        return !path.StartsWith("/health", StringComparison.OrdinalIgnoreCase)
            && !path.StartsWith("/metrics", StringComparison.OrdinalIgnoreCase)
            && !path.StartsWith("/ready", StringComparison.OrdinalIgnoreCase);
    };

    // Enrich spans with additional request details
    options.EnrichWithHttpRequest = (activity, request) =>
    {
        activity.SetTag("http.client_ip",
            request.HttpContext.Connection.RemoteIpAddress?.ToString());
    };
})

The Filter delegate receives the HttpContext and returns a boolean. Returning false means no span is created for that request. The filter executes before the span is started, so there's minimal overhead for filtered paths -- the SDK skips span creation entirely.

EnrichWithHttpRequest is a hook that runs after the span's initial attributes are set. You can use it to add attributes that aren't included by default -- things like client IP, tenant ID from a header, user ID from a claim, or any other request-scoped data relevant to debugging. This is a cleaner approach than modifying Activity.Current in middleware because it's scoped specifically to HTTP request spans.

For error handling patterns in ASP.NET Core using Problem Details or global exception handlers, span status is set based on the response code that ultimately reaches the client. A handled exception that results in a 400 Bad Request won't mark the span as failed. An unhandled exception producing a 500 Internal Server Error will.

Configuring OTLP for All Signals from Shared Settings

A pattern that simplifies multi-signal configuration is registering OtlpExporterOptions once from your configuration and letting all signal exporters read from it automatically.

// A common pattern is to configure OTLP once and apply to all signals
builder.Services.Configure<OtlpExporterOptions>(
    builder.Configuration.GetSection("Otel:Otlp"));

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("MyApp"))
    .WithTracing(t => t
        .AddAspNetCoreInstrumentation()
        .AddOtlpExporter()) // reads from IOptions<OtlpExporterOptions>
    .WithMetrics(m => m
        .AddAspNetCoreInstrumentation()
        .AddOtlpExporter());

// appsettings.json:
// "Otel": {
//   "Otlp": {
//     "Endpoint": "http://otel-collector:4317"
//   }
// }

When you call AddOtlpExporter() without parameters, the SDK reads from IOptions<OtlpExporterOptions>. Registering those options from your configuration section means you have one place to change the endpoint, and it automatically applies to all signals.

This approach also plays well with ASP.NET Core's standard configuration layering. You can set a default endpoint in appsettings.json, override it in appsettings.Production.json, and further override it via an environment variable without touching application code. For teams managing multiple deployment environments, this is a cleaner operational model than per-signal exporter configuration.

Context Propagation in Distributed Systems

One of the core capabilities of OpenTelemetry is propagating trace context across service boundaries. When Service A calls Service B via HTTP, the trace ID and span ID need to travel with the request so Service B can continue the same trace rather than starting a fresh one.

OpenTelemetry ASP.NET Core uses W3C TraceContext (traceparent header) by default. This is the IETF standard and is broadly supported across languages and frameworks -- .NET, Java, Go, Python, Ruby, and many others all implement it. You don't need to configure anything for this to work. Incoming traceparent headers are automatically extracted and used to set the parent span. Outgoing requests made via HttpClient (when AddHttpClientInstrumentation() is configured) automatically include the traceparent header.

The practical result is that end-to-end distributed tracing works in a polyglot microservices environment without any coordination between teams, as long as everyone is running an OpenTelemetry-compatible SDK.

If you're working with legacy systems using B3 propagation (common in older Zipkin-based setups), the OpenTelemetry.Extensions.Propagators package adds a B3Propagator you can register. The two propagation formats can run simultaneously. The SDK will inject both header formats on outbound requests and extract whichever format is present on inbound requests.

Testing and Validating Your OpenTelemetry Setup

Once you have OpenTelemetry configured in ASP.NET Core, you'll want to verify it's working before shipping to production. The simplest approach is temporarily swapping AddOtlpExporter() for AddConsoleExporter() and making a few requests -- trace spans and metric readings will print to the console output.

For a more realistic local environment, run Jaeger or Grafana Tempo via Docker Compose and point your OTLP exporter at http://localhost:4317. You'll see full distributed traces in the UI, which validates both the instrumentation and the export pipeline end-to-end.

For automated integration testing, the in-memory exporter (AddInMemoryExporter() from OpenTelemetry.Exporter.InMemory) is particularly useful. You can assert on collected spans after making requests through WebApplicationFactory. The Testing ASP.NET Core Web APIs guide covers WebApplicationFactory in depth, and the same approach works when you register the in-memory exporter in the test application builder.

Verifying that your Logging in .NET setup is also correlated with traces is worth doing explicitly in at least one integration test -- confirm that log records produced within a span carry the matching TraceId and SpanId.

Frequently Asked Questions

What NuGet packages do I need to set up OpenTelemetry in ASP.NET Core?

The core packages are OpenTelemetry.Extensions.Hosting (provides AddOpenTelemetry()), OpenTelemetry.Instrumentation.AspNetCore (provides AddAspNetCoreInstrumentation()), and either OpenTelemetry.Exporter.Console or OpenTelemetry.Exporter.OpenTelemetryProtocol for export. For runtime metrics, add OpenTelemetry.Instrumentation.Runtime. All are available on NuGet and follow semantic versioning.

Does AddAspNetCoreInstrumentation work with minimal APIs?

Yes. AddAspNetCoreInstrumentation() instruments at the HTTP layer, which is below the routing layer. It creates spans for all incoming requests regardless of whether they're handled by minimal API endpoints, MVC controllers, Razor Pages, or any other request-handling pattern. The http.route attribute reflects the matched route template in each case.

How do I verify OpenTelemetry is working locally before deploying to production?

Replace AddOtlpExporter() with AddConsoleExporter() temporarily. Trace spans and metric readings will print to the console as requests come in. For a more complete validation, run a local Grafana or Jaeger instance via Docker and configure your OTLP exporter to point at http://localhost:4317. You'll see actual distributed traces in the UI, which is the most reliable way to confirm the full pipeline is functioning.

What is a reasonable sample rate for production tracing?

There's no universal answer -- it depends on your traffic volume and backend storage costs. A starting point of 10% (TraceIdRatioBasedSampler(0.1)) is reasonable for moderate-traffic APIs. High-traffic services processing thousands of requests per second might use 1% or lower. Low-traffic internal services can often afford 100%. Monitor your backend ingestion costs and adjust the Otel:SampleRate configuration value accordingly.

Can I add OpenTelemetry to an existing ASP.NET Core application without disruption?

Adding AddOpenTelemetry() is non-breaking. It registers new services alongside your existing setup without modifying anything that's already configured. The only case requiring care is if you're already using a logging provider that might conflict with the OpenTelemetry one, which is uncommon. You can add telemetry incrementally -- start with tracing, then metrics, then logging -- and rollback is as simple as removing the configuration if something goes wrong.

How do I add custom spans for my own business logic?

Create a static ActivitySource field in your class and call StartActivity() to create spans. Register your source with AddSource("MyApp.*") or by exact name in the WithTracing() configuration. Spans created from unregistered sources are silently dropped and not exported -- make sure your source name matches a registered pattern.

What is the difference between AlwaysOn and ParentBased samplers?

AlwaysOnSampler samples every new trace regardless of whether there's a parent. ParentBasedSampler respects the sampling decision from the parent span when one is present. In distributed systems, ParentBasedSampler is generally the right choice because it ensures consistent sampling across all services in a call chain -- if a trace is sampled at the entry point, every downstream service processing it will also sample, giving you complete end-to-end traces for the requests that matter.

Wrapping Up

Setting up OpenTelemetry ASP.NET Core is a straightforward process once you understand the composition model. AddOpenTelemetry() in Program.cs, ConfigureResource() for service identity, WithTracing() and WithMetrics() for the signal pipelines, and AddOtlpExporter() for production export. The whole thing fits in one coherent configuration block.

The auto-instrumentation from AddAspNetCoreInstrumentation() delivers substantial immediate value -- HTTP request spans with timing, status, and route information, plus server metrics for latency dashboards -- without writing a line of custom instrumentation. From there, you layer in custom ActivitySource spans for your business logic, configure samplers to control data volume and cost, and filter out health check noise.

For the full picture of how this fits into an ASP.NET Core application, the ASP.NET Core Web API Complete Guide covers the broader application architecture that observability slots into.


Disclaimer: Yes! This was written by AI based on input and direction from yours truly, Nick Cosentino, for Dev Leader. I have ensured that it represents my thoughts and perspectives on OpenTelemetry with ASP.NET Core setup, auto-instrumentation, and configuration.

OpenTelemetry in .NET: Complete Observability Guide

The complete OpenTelemetry .NET guide for developers: traces, metrics, and logs with C# examples, ASP.NET Core setup, exporters, and distributed tracing.

OpenTelemetry Traces .NET -- ActivitySource, Spans, and the Tracing Pipeline

Learn how OpenTelemetry traces .NET applications use: ActivitySource, Activity, and the tracing pipeline -- with C# examples for ASP.NET Core.

OpenTelemetry Logging .NET: ILogger Integration and Structured Log Export

How to set up OpenTelemetry logging .NET using ILogger -- structured log export, trace context correlation, OTLP export, and practical C# examples.

An error has occurred. This application may no longer respond until reloaded. Reload