.NET observability with OpenTelemetry
Three signals, one plumbing
trace_id , and on the .NET side a span is a System.Diagnostics.Activity . Metrics aggregate counters and histograms (request rate, p95 latency) through a System.Diagnostics.Metrics.Meter . Logs go through ILogger , now attached to the trace_id of the current context.
Activity and Meter instances that your code and libraries already produce, then exports them. You install it via the OpenTelemetry.Extensions.Hosting package and wire it up at startup.
ConfigureResource sets the service.name that identifies the app in the backend. The rest declares what gets listened to and where it's sent.
Auto-instrumentation and manual spans
AddAspNetCoreInstrumentation creates one span per incoming request; AddHttpClientInstrumentation creates one per outgoing call and injects the context into it. These instrumentations follow OpenTelemetry's *semantic conventions*: attributes carry standardized names ( http.request.method , url.path , http.response.status_code ), which makes a trace readable regardless of the backend.
ActivitySource produces nothing until its name is declared via AddSource . Without that line (present in the config above), StartActivity returns null and the span silently disappears.
?. isn't decorative caution: when no listener samples the span, StartActivity returns null , and the code must carry on without flinching. Attributes ( SetTag ) turn the trace into a debugging tool: you filter on order.total > 1000 , pinpoint the exact span that blew up in latency, and SetStatus(Error) marks the trace as faulty so it surfaces at the top of searches.
Counting with metrics
Meter . In ASP.NET Core, you obtain it through injection with IMeterFactory rather than as a static field, which lets the SDK manage its lifecycle.
SuperDev.Orders must match the config's AddMeter , the same rule as for traces. Watch the choice of dimensions: order.channel takes a handful of values, but an order.id tag would blow up cardinality, since each distinct value creates a time series. Unique identifiers belong on a span, never on a counter.
trace_id and span_id of the current context to every ILogger entry. From a slow span, you can then jump straight to the exact logs of that request, without grepping on an approximate timestamp.
Propagating context across services
AddHttpClientInstrumentation serializes the current context into the traceparent header (shaped like 00-{trace_id}-{span_id}-{flags} ), and the ASP.NET Core instrumentation of the called service reads it back to attach its span to the right parent. Nothing to wire up as long as you stay in HTTP.
traceparent into the message properties on send, then re-extract it on receive, via Propagators.DefaultTextMapPropagator . Baggage takes the same channel to propagate business key/value pairs (a tenant.id , for example) throughout the call chain.
OTLP, the Collector, and sampling
OTEL_EXPORTER_OTLP_ENDPOINT variable; switching backends becomes a config change on the Collector side, not a redeploy.
SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1))) keeps 10% of traces while respecting the decision already made upstream, so that a sampled parent keeps its children and a trace stays coherent from one service to another. To keep only the interesting traces (errors, high latency), you move the decision to *tail-based* in the Collector via the tail_sampling processor, once the full trace has been received. The OpenTelemetry documentation for .NET details both strategies.
Instrumenting with OpenTelemetry decouples the code from the monitoring tool. The .NET SDK just listens to the ActivityandMeterinstances the runtime already produces; migrating from Jaeger to Azure Monitor is settled on the Collector side, without touching the application code.