Distributed tracing in .NET with OpenTelemetry: spans that explain failures
This Habr write-up walks through setting up distributed tracing with OpenTelemetry across two ASP.NET Core apps, an OpenTelemetry Collector and Jaeger. It explains when to create your own spans with Activity and ActivitySource, how W3C Trace Context propagates across services, and why a traceId in the response does not guarantee the trace reached Jaeger.
Key points
- Create custom operations with ActivitySource; if the source name is not registered via AddSource you only get automatic HTTP spans in Jaeger.
- Cross-service linking comes from the W3C Trace Context traceparent header, emitted automatically by ASP.NET Core and HttpClient.
- The OTLP endpoint must include the /v1/traces path, and inside Docker Compose you address the collector by service name rather than localhost.
- Keep StartActivity inside a using block and await the work inside it, otherwise the span closes before the operation it measures finishes.
- Do not span every method; pick meaningful boundaries such as validation, persistence and the outbound call.
- A traceId in the response does not prove the trace was exported; check the exporter address and collector pipeline separately.
The article starts from the limits of logs: they show that a request happened but not where the time went across services. It spins up a demo on .NET 10 with two ASP.NET Core apps (a main API and a mock external gateway), an OpenTelemetry Collector and Jaeger, then instruments them. In .NET a span is an Activity, and ActivitySource creates the custom operations that OpenTelemetry subscribes to via AddSource in Program.cs, alongside AddAspNetCoreInstrumentation, AddHttpClientInstrumentation and an OTLP/HTTP exporter.
The key distinction is that linking services and shipping telemetry are separate jobs. W3C Trace Context's traceparent header carries identifiers between calls automatically for HttpClient and ASP.NET Core, while the exporter decides whether anything reaches Jaeger at all. Docker Compose must address the collector by service name, not localhost, and hand-made spans should be opened in a using block with awaited work inside it so they do not close early.
Real-world use
- Measuring which step slows down an order or document submission flow that crosses several microservices.
- Distinguishing whether a record was persisted and whether the outbound HTTP call was made when an external integration times out.
- Investigating a support ticket end to end with a single traceId across services owned by different teams.
- Tracing background worker activity that continues after the originating HTTP request has completed.
Why it matters
With spans at the right boundaries you get evidence of which step stalled, for how long and in which service, instead of a generic failure message. And because the instrumentation is separate from export, you can swap Jaeger for another backend without re-instrumenting business code.