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.