Back to blog
Backend Systemsintermediate

OpenTelemetry in .NET — Traces, Metrics, and Logs in One Setup

Instrument your ASP.NET Core app with OpenTelemetry. Auto-instrument HTTP, EF Core, and HttpClient. Export to Jaeger or OTLP. Add custom spans, counters, histograms, and correlate logs with trace IDs.

LearnixoApril 14, 20265 min read
.NETC#OpenTelemetryObservabilityTracingMetricsLoggingASP.NET Core
Share:𝕏

The Three Pillars

| Pillar | Answers | .NET Type | |---|---|---| | Traces | Where did time go? What path did this request take? | Activity / ActivitySource | | Metrics | How many? How fast? What's the rate? | Meter / Counter / Histogram | | Logs | What happened? What was the state? | ILogger (enriched with trace context) |

OpenTelemetry ties all three together with a shared TraceId so you can jump from a slow metric to the trace to the log line that explains it.


Install Packages

Bash
# Core SDK
dotnet add package OpenTelemetry.Extensions.Hosting

# Instrumentation
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore

# Exporters
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol   # OTLP (Jaeger, Grafana Tempo, etc.)
dotnet add package OpenTelemetry.Exporter.Console                  # dev/debug

Full Setup in Program.cs

C#
var serviceName = "payment-service";
var serviceVersion = "1.0.0";

// Tracing
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(serviceName, serviceVersion: serviceVersion)
        .AddAttributes(new Dictionary<string, object>
        {
            ["deployment.environment"] = builder.Environment.EnvironmentName,
            ["host.name"] = Environment.MachineName
        }))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation(opts =>
        {
            opts.RecordException = true;
            opts.Filter = ctx => !ctx.Request.Path.StartsWithSegments("/health");
        })
        .AddHttpClientInstrumentation(opts =>
        {
            opts.RecordException = true;
            opts.FilterHttpRequestMessage = req =>
                req.RequestUri?.Host != "169.254.169.254"; // skip IMDS
        })
        .AddEntityFrameworkCoreInstrumentation(opts =>
        {
            opts.SetDbStatementForText = true;
            opts.SetDbStatementForStoredProcedure = true;
        })
        .AddSource(PaymentService.ActivitySourceName) // custom spans
        .AddOtlpExporter(opts =>
        {
            opts.Endpoint = new Uri("http://localhost:4317");
            opts.Protocol = OtlpExportProtocol.Grpc;
        }))
    .WithMetrics(metrics => metrics
        .AddAspNetCoreInstrumentation()
        .AddHttpClientInstrumentation()
        .AddRuntimeInstrumentation()      // GC, thread pool, memory
        .AddMeter(PaymentService.MeterName) // custom metrics
        .AddOtlpExporter())
    .WithLogging(logging => logging
        .AddOtlpExporter());

// Enrich ILogger output with TraceId and SpanId
builder.Logging.AddOpenTelemetry(opts =>
{
    opts.IncludeFormattedMessage = true;
    opts.IncludeScopes = true;
});

Custom Spans with ActivitySource

Activity is the .NET equivalent of an OpenTelemetry span.

C#
public class PaymentService(IPaymentRepository repo, ILogger<PaymentService> logger)
{
    public static readonly string ActivitySourceName = "PaymentService";
    public static readonly string MeterName = "PaymentService";

    private static readonly ActivitySource _activitySource = new(ActivitySourceName);

    public async Task<PaymentResult> ProcessPaymentAsync(
        PaymentRequest request, CancellationToken ct = default)
    {
        using var activity = _activitySource.StartActivity("ProcessPayment");

        // Add structured tags to the span
        activity?.SetTag("payment.currency", request.Currency);
        activity?.SetTag("payment.amount_minor", request.AmountMinor);
        activity?.SetTag("payment.merchant_id", request.MerchantId);

        try
        {
            var result = await ChargeAsync(request, ct);

            activity?.SetTag("payment.result", result.Status.ToString());
            activity?.SetStatus(ActivityStatusCode.Ok);

            return result;
        }
        catch (PaymentDeclinedException ex)
        {
            activity?.SetTag("payment.decline_reason", ex.Reason);
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            // Don't re-record the exception — it's a business outcome, not a bug
            throw;
        }
        catch (Exception ex)
        {
            activity?.RecordException(ex);
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            throw;
        }
    }
}

Child Spans — Nested Activities

Child spans appear nested under the parent in Jaeger/Tempo:

C#
private async Task<ChargeResult> ChargeAsync(PaymentRequest request, CancellationToken ct)
{
    using var activity = _activitySource.StartActivity("ChargeCard");
    activity?.SetTag("card.last4", request.CardLast4);

    // This EF Core query will automatically appear as a child span
    var account = await repo.GetAccountAsync(request.AccountId, ct);

    using var gatewayActivity = _activitySource.StartActivity("CallPaymentGateway");
    gatewayActivity?.SetTag("gateway.provider", "stripe");

    var result = await gatewayClient.ChargeAsync(request, ct);

    gatewayActivity?.SetTag("gateway.charge_id", result.ChargeId);

    return result;
}

Custom Metrics with Meter

C#
public class PaymentMetrics : IDisposable
{
    private readonly Meter _meter;

    // Counter — increases monotonically
    private readonly Counter<long> _paymentsProcessed;

    // Histogram — records a distribution (latency, sizes)
    private readonly Histogram<double> _paymentDuration;

    // Observable Gauge — sampled on demand
    private readonly ObservableGauge<int> _pendingPayments;

    private int _pendingCount;

    public PaymentMetrics()
    {
        _meter = new Meter(PaymentService.MeterName, "1.0.0");

        _paymentsProcessed = _meter.CreateCounter<long>(
            "payments.processed",
            unit: "{payments}",
            description: "Total number of payments processed");

        _paymentDuration = _meter.CreateHistogram<double>(
            "payments.duration",
            unit: "ms",
            description: "Payment processing duration");

        _pendingPayments = _meter.CreateObservableGauge<int>(
            "payments.pending",
            () => _pendingCount,
            unit: "{payments}",
            description: "Number of payments currently in processing");
    }

    public void RecordPayment(string currency, string status, double durationMs)
    {
        var tags = new TagList
        {
            { "currency", currency },
            { "status", status }
        };

        _paymentsProcessed.Add(1, tags);
        _paymentDuration.Record(durationMs, tags);
    }

    public void SetPending(int count) => _pendingCount = count;

    public void Dispose() => _meter.Dispose();
}

Register as a singleton:

C#
builder.Services.AddSingleton<PaymentMetrics>();

Use in services:

C#
public async Task<PaymentResult> ProcessPaymentAsync(PaymentRequest request, CancellationToken ct)
{
    var sw = Stopwatch.StartNew();
    try
    {
        var result = await ChargeAsync(request, ct);
        _metrics.RecordPayment(request.Currency, result.Status.ToString(), sw.Elapsed.TotalMilliseconds);
        return result;
    }
    catch
    {
        _metrics.RecordPayment(request.Currency, "error", sw.Elapsed.TotalMilliseconds);
        throw;
    }
}

Correlating Logs With Traces

When AddOpenTelemetry() is configured on ILoggerProvider, log entries are automatically enriched with TraceId and SpanId:

JSON
{
  "Timestamp": "2026-04-14T12:00:00Z",
  "Level": "Information",
  "Message": "Payment processed for merchant stripe_abc",
  "TraceId": "4bf92f3577b34da6a3ce929d0e0e4736",
  "SpanId": "00f067aa0ba902b7",
  "Properties": {
    "merchant_id": "stripe_abc",
    "payment.amount_minor": 9900
  }
}

In Grafana, click a trace span → "View Logs" → all log lines from that span appear filtered by TraceId. Zero configuration.


Exporting to Jaeger (Dev Setup)

YAML
# docker-compose.yml
services:
  jaeger:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"  # Jaeger UI
      - "4317:4317"    # OTLP gRPC
      - "4318:4318"    # OTLP HTTP
C#
// appsettings.Development.json
{
  "OTLP": {
    "Endpoint": "http://localhost:4317"
  }
}

Open http://localhost:16686 — your traces appear immediately.


Sampling — Reducing Volume in Production

C#
.WithTracing(tracing => tracing
    // ... instrumentation ...
    .SetSampler(new TraceIdRatioBasedSampler(0.1)) // sample 10% of traces
    // Or: always sample errors regardless of rate
    .SetSampler(new ParentBasedSampler(new TraceIdRatioBasedSampler(0.1)))
    .AddOtlpExporter());

Always sample 100% in dev; use ratio sampling in production. Some collectors (Grafana Agent, OpenTelemetry Collector) support tail-based sampling — sample 100% of errored traces, 5% of successful ones.


Activity.Current — Access Trace Context Anywhere

C#
// Add custom tags from deep in the call stack without threading Activity through
public class AuditService
{
    public void RecordAudit(string action, string userId)
    {
        // Enriches the current span — no Activity parameter needed
        Activity.Current?.SetTag("audit.action", action);
        Activity.Current?.SetTag("audit.user_id", userId);
    }
}

Key Takeaways

  • One AddOpenTelemetry() call wires up traces, metrics, and logs with a shared TraceId
  • ASP.NET Core, HttpClient, and EF Core are auto-instrumented — no manual spans needed for framework code
  • ActivitySource + Activity for custom spans; Meter + Counter/Histogram for custom metrics
  • OTLP exporter works with Jaeger, Grafana Tempo, Honeycomb, Lightstep, and AWS X-Ray
  • Logs get TraceId/SpanId automatically when OpenTelemetry logging is configured
  • Use TraceIdRatioBasedSampler in production — full sampling is expensive at scale

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.