OpenTelemetry Traces: Complete .NET Setup
OpenTelemetry traces capture distributed requests flowing through your .NET microservices, but setup involves more than calling AddConsoleExporter(). Configuring sampling (to reduce data volume), batching (for network efficiency), and resource attributes (to identify your service) transforms traces from learning tool to production asset.
Tracing without proper configuration led one team to export 10 terabytes of span data monthly—costing them thousands in storage. After adding probabilistic sampling and batch processing, they reduced data to 100 gigabytes while maintaining visibility into errors and slow requests. This article shows you that configuration from day one.
Setting Up TracerProvider with Resource Attributes
A resource identifies your service in observability backends. When Jaeger or Prometheus receives spans from your application, the resource attributes make it clear which service, deployment, and version the data came from:
using OpenTelemetry;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
var resource = ResourceBuilder
.CreateDefault()
.AddService(
serviceName: "order-service",
serviceVersion: "1.2.0"
)
.AddAttributes(new Dictionary{string, object}
{
{ "deployment.environment", "production" },
{ "service.namespace", "ecommerce" },
})
.Build();
var tracerProvider = new TracerProviderBuilder()
.SetResource(resource)
.AddConsoleExporter()
.Build();
The ResourceBuilder collects metadata about your service. The AddService() method is the most important—it sets service.name and service.version, which backends use to build the dependency graph. Custom attributes via AddAttributes() help with filtering and correlation.
Configuring Sampling Strategies
Sampling reduces the volume of spans exported. Without sampling, high-traffic services generate millions of spans per minute, overwhelming exporters and backends. OpenTelemetry offers three sampling strategies:
| Sampler | Behavior | Use Case |
|---|---|---|
AlwaysSampleSampler | Every span is sampled (exported) | Development, low-traffic services |
ProbabilitySampler | A percentage (e.g., 10%) of spans | High-traffic production services |
TraceIdRatioBasedSampler | Consistent sampling per trace (all spans in a trace are sampled together) | Distributed systems requiring causal consistency |
Here is a production-ready example using probabilistic sampling:
var tracerProvider = new TracerProviderBuilder()
.SetResource(resource)
.SetSampler(new TraceIdRatioBasedSampler(0.1)) // Sample 10% of traces
.AddConsoleExporter()
.Build();
At 0.1 (10%), if your service processes 1 million requests per hour, you export 100,000 trace trees—still enough to catch 99% of errors (which are over-sampled by observability systems) and performance anomalies. Adjust the ratio based on traffic volume and storage budget.
Batch Processing for Efficient Export
By default, each span triggers an export. In network-heavy applications, this causes thousands of HTTP calls to your backend per second. Batch processing collects spans into groups and exports them together, reducing network overhead by 100-fold:
using OpenTelemetry.Trace;
var tracerProvider = new TracerProviderBuilder()
.SetResource(resource)
.SetSampler(new TraceIdRatioBasedSampler(0.1))
.AddBatchSpanProcessor(new BatchSpanProcessorOptions
{
MaxExportBatchSize = 512, // Export when 512 spans are queued
MaxQueueSize = 2048, // Hold up to 2048 spans in memory
ExportTimeoutMilliseconds = 30000, // Export every 30 seconds
ScheduledDelayMilliseconds = 5000 // Check queue every 5 seconds
})
.AddConsoleExporter()
.Build();
The BatchSpanProcessor accumulates spans in a queue and exports them when the queue size exceeds MaxExportBatchSize or the ExportTimeoutMilliseconds interval elapses, whichever comes first. This design prevents memory bloat while ensuring spans are not delayed indefinitely.
Configuring Source Instrumentation
Instrumentation is the process of marking which libraries or components should emit spans. By default, OpenTelemetry only exports spans you create manually. To automatically trace ASP.NET Core, Entity Framework, and HttpClient calls, add instrumentation sources:
using OpenTelemetry.Instrumentation.AspNetCore;
using OpenTelemetry.Instrumentation.Http;
using OpenTelemetry.Instrumentation.SqlClient;
var tracerProvider = new TracerProviderBuilder()
.SetResource(resource)
.SetSampler(new TraceIdRatioBasedSampler(0.1))
.AddAspNetCoreInstrumentation(options =>
{
options.Filter = context =>
!context.Request.Path.StartsWithSegments("/health");
})
.AddHttpClientInstrumentation()
.AddSqlClientInstrumentation()
.AddBatchSpanProcessor(new BatchSpanProcessorOptions { ... })
.AddConsoleExporter()
.Build();
Each instrumentation library emits spans for framework operations. The Filter option excludes noise (like /health endpoint polls) from tracing.
In-Memory Span Storage for Testing
For unit and integration tests, export spans to an in-memory collection instead of stdout or a remote backend:
var spans = new List{Activity}();
var tracerProvider = new TracerProviderBuilder()
.SetResource(resource)
.AddInMemoryExporter(spans)
.Build();
// Run your code
var tracer = tracerProvider.GetTracer("TestService");
using (var span = tracer.StartSpan("TestOperation"))
{
span.SetAttribute("test.id", "001");
}
// Assert on spans
Assert.Single(spans);
Assert.Equal("TestOperation", spans[0].DisplayName);
Assert.Equal("001", spans[0].GetTagValue("test.id"));
This pattern allows you to write tests that verify instrumentation is working correctly without depending on external backends.
Key Takeaways
- Resource attributes (service name, version, environment) are essential for identifying your service in dashboards and dependency graphs.
- Sampling (especially trace-ID-based) reduces export volume without losing visibility into errors and slow transactions.
- Batch processing improves efficiency by grouping spans before network transmission, reducing overhead from millions of export calls.
- Automatic instrumentation for ASP.NET Core, HttpClient, and SQL Client provides coverage without code changes.
- In-memory exporters enable deterministic testing of trace behavior.
Frequently Asked Questions
What sampling ratio should I use in production?
For most services, 5–10% (0.05–0.1) is sufficient for error detection and SLO monitoring. High-traffic services can go lower (1–2%); low-traffic services can sample 100%. Adjust based on data volume and storage cost.
What happens to spans if the export queue fills up?
By default, new spans are dropped (oldest discarded first). For critical traces, you can configure the MaxQueueSize higher or use a storage backend with retry logic (covered in article 7).
Can I change sampling rules at runtime?
Yes. Use a remote sampler (not covered here) to fetch sampling rules from a central service, allowing you to adjust sampling without redeploying.
Do I need to add AddAspNetCoreInstrumentation() if I use Kestrel?
If your .NET application uses ASP.NET Core (even Minimal APIs), yes. Kestrel is the HTTP server layer; ASP.NET Core is the application framework that benefits from instrumentation.
Why use TraceIdRatioBasedSampler instead of ProbabilitySampler?
TraceIdRatioBasedSampler ensures all spans from a single trace are sampled together (consistent). ProbabilitySampler samples independently per span, risking incomplete traces. Use trace-ID-based sampling in all distributed systems.