OpenTelemetry in .NET: traces, metrics, and logs with minimal boilerplate
Published: 2026-03-13
OpenTelemetry is the observability standard that replaces vendor-specific SDKs. One instrumentation setup, exporters swapped at config time. For .NET apps in Kubernetes, it means traces in Jaeger, metrics in Prometheus, and structured logs in whatever sink you prefer — all from the same SDK initialization.
NuGet packages
xml<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.*" />
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.*" />
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.*-beta*" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.*" />
Separate packages for instrumentation (what to collect) and exporters (where to send). Use the AspNetCore Prometheus exporter to expose metrics as a /metrics endpoint that Prometheus scrapes.
SDK setup in Program.cs
csharpvar builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService(
serviceName: "my-service",
serviceVersion: Environment.GetEnvironmentVariable("APP_VERSION") ?? "dev"))
.AddAspNetCoreInstrumentation(opts =>
{
opts.RecordException = true;
opts.Filter = ctx => ctx.Request.Path != "/healthz";
})
.AddHttpClientInstrumentation()
.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri(
builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]
?? "http://otel-collector.observability.svc.cluster.local:4317");
}))
.WithMetrics(metrics => metrics
.SetResourceBuilder(ResourceBuilder.CreateDefault()
.AddService("my-service"))
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation()
.AddPrometheusExporter());
var app = builder.Build();
app.MapPrometheusScrapingEndpoint();
Environment variables for configuration
yamlenv:
- name: OTEL_EXPORTER_OTLP_ENDPOINT
value: "http://otel-collector.observability.svc.cluster.local:4317"
- name: OTEL_SERVICE_NAME
value: "my-service"
- name: OTEL_RESOURCE_ATTRIBUTES
value: "deployment.environment=prod,k8s.namespace=app"
- name: APP_VERSION
value: "1.2.3"
The SDK reads OTEL_SERVICE_NAME automatically. No code changes between environments — just different env vars.
Custom traces: spans for business operations
csharpprivate static readonly ActivitySource _tracer =
new ActivitySource("my-service.business");
public async Task<Order> ProcessOrderAsync(Guid orderId)
{
using var span = _tracer.StartActivity("ProcessOrder");
span?.SetTag("order.id", orderId.ToString());
try
{
var order = await _repo.GetAsync(orderId);
span?.SetTag("order.status", order.Status.ToString());
span?.SetTag("order.amount", order.Total);
await _paymentService.ChargeAsync(order);
span?.SetStatus(ActivityStatusCode.Ok);
return order;
}
catch (Exception ex)
{
span?.SetStatus(ActivityStatusCode.Error, ex.Message);
span?.RecordException(ex);
throw;
}
}
Register the source:
csharp.WithTracing(tracing => tracing
.AddSource("my-service.business")
...
Custom metrics
csharpprivate static readonly Meter _meter = new Meter("my-service.business");
private static readonly Counter<long> _ordersProcessed =
_meter.CreateCounter<long>("orders_processed_total");
private static readonly Histogram<double> _orderAmount =
_meter.CreateHistogram<double>("order_amount_eur");
_ordersProcessed.Add(1, new TagList { { "status", "success" } });
_orderAmount.Record(order.Total, new TagList { { "currency", "EUR" } });
Register:
csharp.WithMetrics(metrics => metrics
.AddMeter("my-service.business")
...
These metrics appear on /metrics and are scraped by Prometheus via the ServiceMonitor.
Connecting logs to traces
csharp// Add correlation: trace_id and span_id automatically added to structured logs
builder.Logging.AddOpenTelemetry(logging =>
{
logging.IncludeFormattedMessage = true;
logging.IncludeScopes = true;
logging.AddOtlpExporter(opts =>
{
opts.Endpoint = new Uri(
builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]
?? "http://otel-collector.observability.svc.cluster.local:4317");
});
});
With this, every log entry gets trace_id and span_id attributes. In Grafana, you can link from a trace span to the related logs.
OTel Collector deployment
yamlapiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: otel-collector
namespace: observability
spec:
chart:
spec:
chart: opentelemetry-collector
version: "0.x"
sourceRef:
kind: HelmRepository
name: open-telemetry
values:
mode: deployment
config:
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
exporters:
otlp/jaeger:
endpoint: jaeger-collector.observability.svc.cluster.local:4317
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
exporters: [otlp/jaeger]
Apps send to the Collector; the Collector fans out to multiple backends. Change backends without touching app configuration.
ServiceMonitor for Prometheus scraping
yamlapiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: my-service
namespace: observability
labels:
release: kube-prom-stack
spec:
selector:
matchLabels:
app: my-service
endpoints:
- port: http
path: /metrics
interval: 30s
namespaceSelector:
matchNames:
- app
The /metrics endpoint from MapPrometheusScrapingEndpoint() exposes all registered metrics including the OTel SDK ones.
Troubleshooting traces not appearing
bash# Check if the app can reach the OTel Collector
kubectl exec -n app deploy/my-service -- \
wget -qO- http://otel-collector.observability.svc.cluster.local:4317
# OTel debug logging: add to env
- name: OTEL_LOG_LEVEL
value: "debug"
# Check collector logs
kubectl logs -n observability deploy/otel-collector --tail=50
Common issues: wrong namespace in the OTLP endpoint URL, insecure: true missing on the collector exporter, AddSource not called for custom ActivitySources.