BrandGhost
OpenTelemetry HttpClient Instrumentation in .NET: Tracing Outbound HTTP Calls

OpenTelemetry HttpClient Instrumentation in .NET: Tracing Outbound HTTP Calls

If you've ever stared at a slow API response and wondered where the time actually went, you know the frustration of incomplete observability. Tracing inbound requests is a great start. But without OpenTelemetry HttpClient instrumentation in .NET covering your outbound calls, you're flying blind the moment your service talks to anything downstream. You can see that a request to your service took 800ms -- but was 700ms of that a database call? A downstream REST API? A retry storm? Without outbound tracing, you simply can't tell. This guide walks through everything you need: from the one-line setup to filtering, enrichment, W3C trace propagation, and full production configuration.

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

Why Outbound HTTP Tracing Matters

Modern .NET services rarely stand alone. They call payment processors, inventory APIs, identity providers, and a dozen other services before they can respond to a single request. Each of those outbound calls is a potential source of latency and failure.

Without outbound HTTP tracing, your observability has a fundamental blind spot. You might know your service's p99 latency is 800ms. But you don't know how much of that is your own code versus downstream services. You don't know which specific endpoints are slow. You don't know whether failures are coming from one particularly flaky dependency or spread evenly across all of them.

OpenTelemetry fills this gap automatically. When you add AddHttpClientInstrumentation(), every outbound HttpClient call becomes a span in your distributed trace. You get the URL, the HTTP method, the status code, and the exact duration -- all without writing a single line of custom instrumentation code.

The real power comes from trace propagation. When your service makes an outbound call, OpenTelemetry automatically injects the W3C traceparent header. The downstream service -- assuming it's also instrumented -- creates its spans as children of that same trace. Suddenly you have a complete picture of the entire request journey across service boundaries.

Getting Started: OpenTelemetry HttpClient Instrumentation in .NET

The primary package for OpenTelemetry HttpClient instrumentation in .NET is OpenTelemetry.Instrumentation.Http. It's separate from the core SDK, so you'll add it explicitly:

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

With the package installed, wiring up outbound HTTP tracing is a single method call in your tracing builder:

using OpenTelemetry.Trace;

builder.Services.AddOpenTelemetry()
    .ConfigureResource(r => r.AddService("MyApp"))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation() // inbound
        .AddHttpClientInstrumentation() // outbound
        .AddOtlpExporter());

That is the complete setup for basic outbound HTTP tracing. Every HttpClient request your application makes will automatically produce a span -- regardless of whether the client was created through IHttpClientFactory, injected as a typed client, or instantiated directly. Note: span creation via the DiagnosticListener infrastructure works for all HttpClient instances. W3C traceparent header injection for context propagation, however, requires clients created through IHttpClientFactory -- see the Distributed Tracing article in this series for details.

For a thorough understanding of how HttpClient works in .NET and why IHttpClientFactory is the preferred pattern, the HttpClient in C# Complete Guide covers the fundamentals in detail.

What Gets Instrumented Automatically

When OpenTelemetry instruments an outbound HTTP request, it creates a span that follows the OpenTelemetry semantic conventions for HTTP. The span automatically captures:

  • http.request.method -- GET, POST, PUT, DELETE, and so on
  • server.address and server.port -- the target host and port
  • url.full -- the complete request URI
  • http.response.status_code -- the HTTP status code from the response
  • error.type -- populated when the request fails or returns a 4xx/5xx response
  • Span duration -- the complete elapsed time from request send to response completion

These attributes follow the semantic conventions so they render correctly in tools like Jaeger, Grafana Tempo, Honeycomb, and SigNoz without any extra configuration on your part. The span name follows the pattern HTTP {METHOD}, giving you readable span names like HTTP GET and HTTP POST in your trace viewer.

Status semantics are also handled automatically. A 4xx or 5xx response sets the span status to ERROR. Your trace backend can immediately flag these as failing spans and surface them in error dashboards -- no manual span status configuration required.

OpenTelemetry HttpClient .NET and W3C Trace Context Propagation

This is where distributed tracing becomes genuinely powerful. When your service makes an outbound HttpClient call, OpenTelemetry automatically injects a traceparent header into the request. That header carries the current trace ID and the ID of the span representing the outbound call.

When the downstream service processes that request -- and that service is also instrumented with OpenTelemetry -- it reads the traceparent header and creates its own spans as children of the propagated context. The result: a single distributed trace that spans multiple services, giving you a complete view of the request flow from the original entry point all the way through to the deepest downstream dependency.

This propagation happens automatically via the DelegatingHandler chain that OpenTelemetry hooks into. You don't need to write any header injection code. You don't need to thread a correlation ID through your method signatures. The W3C Trace Context standard handles it, and AddHttpClientInstrumentation() implements it.

IHttpClientFactory and Typed Clients

Good news if you're following the recommended pattern and using IHttpClientFactory (and you should be -- the guide on IHttpClientFactory in .NET explains why). The AddHttpClientInstrumentation() call works seamlessly with all IHttpClientFactory patterns: named clients, typed clients, and the default client.

The instrumentation hooks into the DelegatingHandler chain that IHttpClientFactory manages internally. This means trace propagation and span creation happen at the right level in the HTTP pipeline -- after authentication handlers add their headers, and at the right point for accurate timing measurement.

Here's a typed client showing just how transparent the instrumentation is:

using System.Net.Http.Json;

// Typed client -- instrumentation works transparently
public class InventoryClient
{
    private readonly HttpClient _httpClient;

    public InventoryClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
        _httpClient.BaseAddress = new Uri("https://inventory.internal");
    }

    public async Task<InventoryItem?> GetItemAsync(string sku)
    {
        // The traceparent header is automatically injected here
        // The downstream service will see the same traceId
        var response = await _httpClient.GetAsync($"/items/{sku}");
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadFromJsonAsync<InventoryItem>();
    }
}

// Registration in Program.cs
builder.Services.AddHttpClient<InventoryClient>();
// No extra configuration needed for trace propagation

No extra configuration. No manual header injection. The typed client is just a typed client. OpenTelemetry handles trace propagation entirely behind the scenes.

Filtering Requests from Tracing

Not every HTTP call deserves a trace span. Health check endpoint pings, internal monitoring calls, and telemetry self-reporting calls are strong candidates for exclusion. Including them adds noise to your traces and can inflate span volume -- a consideration if your observability backend charges per span.

The FilterHttpRequestMessage option gives you precise control over which requests get traced. Return true to include a request, false to skip it:

.AddHttpClientInstrumentation(options =>
{
    // Don't trace calls to internal health check endpoints
    options.FilterHttpRequestMessage = request =>
    {
        var host = request.RequestUri?.Host ?? string.Empty;
        return !host.EndsWith(".health.internal", StringComparison.OrdinalIgnoreCase);
    };

    // Record exception events on failed requests
    options.RecordException = true;

    // Add custom tags from the request
    options.EnrichWithHttpRequestMessage = (activity, request) =>
    {
        activity.SetTag("http.request.content_type",
            request.Content?.Headers.ContentType?.MediaType);
    };

    // Add custom tags from the response
    options.EnrichWithHttpResponseMessage = (activity, response) =>
    {
        activity.SetTag("http.response.content_length",
            response.Content.Headers.ContentLength);
    };
})

Keep the filter callback fast. It runs synchronously on every request, so string comparisons and simple URL checks are fine. Avoid I/O, expensive computations, or anything that could block the HTTP send path.

Enriching Spans with Custom Tags

The automatic attributes cover standard HTTP semantics well. But sometimes you need more context on a span -- a tenant ID, a custom correlation header, the content type of a request body, or any other business-level attribute that isn't part of the standard semantic conventions.

OpenTelemetry HttpClient .NET instrumentation provides two enrichment callbacks for exactly this purpose. EnrichWithHttpRequestMessage runs before the request completes and gives you access to the HttpRequestMessage. EnrichWithHttpResponseMessage runs after and gives you the HttpResponseMessage. Both receive the current Activity so you can call SetTag() directly.

This is particularly useful when you want to add business-level tags to HTTP spans without modifying the client code itself. You configure enrichment once in AddHttpClientInstrumentation(), and it applies uniformly to every instrumented request.

If you need per-request enrichment based on data only available at the call site, a custom DelegatingHandler is an alternative approach. A handler can read business context from the current Activity, an ambient scope, or a scoped service and add tags directly to the span. The HttpClient Logging and Observability in .NET guide covers complementary patterns for building observability into your HTTP pipeline.

Recording Exceptions on Failed Requests

When an HttpClient call throws an exception -- a timeout, a network failure, a TLS handshake error -- OpenTelemetry can automatically record that exception as a span event. Enable it with options.RecordException = true.

This is distinct from the automatic error status propagation. Error status tells your trace backend "this span failed." Recording exceptions adds the exception type, message, and stack trace as span events, giving you the raw exception details directly in your trace viewer alongside the span that failed.

The difference is visible when you're debugging intermittent failures. With just error status, you know a request failed. With recorded exceptions, you can see at a glance whether it was a TaskCanceledException (timeout hit), an HttpRequestException (network error), or something else -- right there on the span, without correlating logs separately.

If you're combining trace context with structured logging, the Logging in .NET: The Complete Developer's Guide shows how to align your log records with your trace context so both tell the same story.

Manual Trace Context Propagation for Edge Cases

There are scenarios where you can't use HttpClient directly -- legacy code using WebClient, third-party SDKs that manage their own HTTP internals, or non-HTTP transports where you still want to propagate context. For these cases, OpenTelemetry provides manual propagation via Propagators.DefaultTextMapPropagator:

using System.Diagnostics;
using OpenTelemetry;
using OpenTelemetry.Context.Propagation;

// Manual propagation when you can't use HttpClient directly
public async Task CallLegacyServiceAsync(string url)
{
    using var activity = MyActivitySource.StartActivity("CallLegacyService");

    var request = new HttpRequestMessage(HttpMethod.Get, url);

    // Manually inject trace context into headers
    Propagators.DefaultTextMapPropagator.Inject(
        new PropagationContext(activity?.Context ?? default, Baggage.Current),
        request.Headers,
        (headers, key, value) => headers.TryAddWithoutValidation(key, value));

    using var response = await _httpClient.SendAsync(request);
    response.EnsureSuccessStatusCode();
}

Here MyActivitySource refers to a static ActivitySource instance defined in your application. This pattern is typically used when bridging between automatic and manual instrumentation, or when working with HTTP clients that don't participate in the standard HttpClient pipeline.

Complete Setup: Inbound, Outbound, and Logging Together

Let's put everything together. A production-ready setup for an ASP.NET Core service combines inbound request tracing, outbound HttpClient tracing, and log correlation. Here's a complete configuration for an order service:

builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService("OrderService", serviceVersion: "2.1.0")
        .AddAttributes(new Dictionary<string, object>
        {
            ["deployment.environment"] = builder.Environment.EnvironmentName
        }))
    .WithTracing(tracing => tracing
        .AddSource("OrderService.*")
        .AddAspNetCoreInstrumentation(options =>
        {
            options.Filter = ctx =>
                !ctx.Request.Path.StartsWithSegments("/health");
        })
        .AddHttpClientInstrumentation(options =>
        {
            options.RecordException = true;
        })
        .AddOtlpExporter());

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

A few things worth noting here. The AddSource("OrderService.*") call registers the custom activity sources from this application's own manual instrumentation. Without it, custom spans from your business logic won't appear in traces even if you're creating them correctly. The ASP.NET Core instrumentation filters out /health requests to keep traces clean. And IncludeScopes = true on the logging setup ensures the current trace ID is included in log records, giving you correlated traces and logs in your backend.

For comprehensive guidance on building the ASP.NET Core services that this kind of setup applies to, the ASP.NET Core Web API Complete Guide is a solid reference.

If your services have resilience requirements layered on top of observability, HttpClient Resilience in .NET covers retry policies and circuit breakers -- and how they interact with tracing in ways worth understanding. Each retry can appear as its own child span, which affects how you interpret trace timelines.

OpenTelemetry HttpClient .NET: Performance Considerations

Instrumentation isn't free. Each span involves allocation, attribute assignment, and export buffering. For most services, the per-request overhead is measured in microseconds and is unlikely to be a bottleneck. But a few patterns are worth being aware of.

The filter callback runs synchronously on every request. A slow filter delays the HTTP call itself. Keep it to simple string comparisons or boolean flag checks.

Enrichment callbacks also run synchronously. If you're reading from a shared resource or accessing a scoped service through IServiceProvider, keep those operations fast. Accessing HttpContext from an enrichment callback requires an injected IHttpContextAccessor and should be done carefully.

Setting RecordException = true adds exception details as span events. For sporadic failures, this is negligible. If your application is consistently throwing exceptions on most requests -- which would be a separate problem to fix -- the added span event data can contribute meaningfully to export volume.

FAQ

What NuGet package do I need for OpenTelemetry HttpClient .NET instrumentation?

The primary package is OpenTelemetry.Instrumentation.Http. You'll also need OpenTelemetry.Extensions.Hosting for the AddOpenTelemetry() extension methods, and at least one exporter package such as OpenTelemetry.Exporter.OpenTelemetryProtocol to send telemetry somewhere. These are all separate packages in the OpenTelemetry .NET SDK, so each one needs to be added to your project file explicitly.

Does OpenTelemetry HttpClient instrumentation work with IHttpClientFactory?

Yes, it integrates seamlessly with all IHttpClientFactory patterns -- named clients, typed clients, and the default client. The instrumentation hooks into the DelegatingHandler chain that IHttpClientFactory uses for HTTP message handling, so you don't need any extra configuration beyond calling AddHttpClientInstrumentation().

How does W3C trace context propagation work with HttpClient?

When AddHttpClientInstrumentation() is active, OpenTelemetry automatically injects a traceparent header into every outbound HttpClient request. This header carries the current trace ID and parent span ID. A downstream service that's also instrumented with OpenTelemetry reads this header and creates its spans as children of the propagated context, resulting in a single distributed trace that spans both services.

Can I exclude certain URLs from being traced?

Yes. Use the FilterHttpRequestMessage option on AddHttpClientInstrumentation(). Return true to include a request, false to exclude it. This is commonly used to filter out health check calls, monitoring pings, or calls to the telemetry exporter endpoint itself. Keep the lambda lightweight -- it runs synchronously before every request.

What is the difference between RecordException and error status on spans?

Error status (set automatically when a request returns 4xx/5xx) marks the span as failed in your trace backend. RecordException = true goes further by adding a span event containing the exception type, message, and stack trace when an HttpClient call throws. Both can be active simultaneously. Error status answers "did this request fail?" -- recorded exceptions answer "why did it fail and what was the exact error?"

Does HttpClient instrumentation capture request and response bodies?

Not by default, and this is intentional. Request and response bodies can be large, may contain sensitive data, and capturing them would significantly increase span sizes and export costs. If you need body content in traces, use the EnrichWithHttpRequestMessage and EnrichWithHttpResponseMessage callbacks to selectively extract and add specific fields after parsing the body in your application code.

How do I trace HttpClient calls in a .NET console application (not ASP.NET Core)?

The setup is nearly identical. You still call AddOpenTelemetry() on IServiceCollection, and you still call AddHttpClientInstrumentation(). The main difference is that instead of builder.Services from WebApplicationBuilder, you're working with a HostBuilder and its Services collection. The instrumentation itself is not specific to ASP.NET Core -- it works in any .NET host that uses IHttpClientFactory or HttpClient.


Wrapping Up

OpenTelemetry HttpClient .NET instrumentation is one of those additions that pays for itself immediately. One method call gives you automatic spans for every outbound call, W3C trace propagation to downstream services, and error status tracking. The filtering and enrichment options let you tune signal quality without touching your actual client code. And when you combine inbound and outbound instrumentation together, you get a complete picture of what your service is doing and exactly where time is being spent.

The next step after getting spans flowing is usually to ensure your logs carry the current trace ID so you can navigate between traces and logs seamlessly. The Logging in .NET: The Complete Developer's Guide covers that correlation story in depth.

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 HttpClient instrumentation in .NET.

Weekly Recap: Logging in .NET, HttpClient Observability, and the Complete C# Visitor Pattern Series [Jul 2026]

This week wraps up the C# Visitor pattern series with real-world examples, best practices, a decision guide, and step-by-step implementation, plus a complete guide to logging in .NET with ILogger, Serilog, and OpenTelemetry. There's also deep HttpClient in .NET 10 coverage on observability, resilience, streaming, and DNS handling, along with practical guides to creating and understanding NuGet packages.

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

Set up OpenTelemetry ASP.NET Core with AddOpenTelemetry(), auto-instrumentation, metrics collection, and practical configuration examples for .NET 10.

HttpClient Logging and Observability in .NET 10: ILogger, DelegatingHandler, and OpenTelemetry

How to implement HttpClient logging and observability in .NET 10 -- covering built-in ILogger integration, DelegatingHandler for custom request logging, OpenTelemetry tracing and metrics, and redacting sensitive headers in production.

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