Trace Smarter: Diagnostics Patterns for ASP.NET Core

Why Tracing Matters

If you've ever debugged a slow API request that calls three microservices and two databases, you know the frustration of scattered logs with no correlation. Each service logs independently, timestamps don't align, and you can't tell which database query belongs to which request. Production issues take hours to diagnose because you're piecing together fragments across systems.

This article shows how ASP.NET Core's built-in Activity and DiagnosticSource remove that pain by automatically tracking requests across services with correlation IDs and timing data. You'll build observable apps where each request has a trace ID that follows it through your entire stack, making debugging systematic instead of guesswork.

You'll learn Activity for distributed tracing, DiagnosticSource for custom instrumentation, OpenTelemetry integration for exporting traces, and practical patterns for correlation IDs in logs. By the end, you'll trace requests from client to database and back with full visibility into where time is spent.

Understanding Activity for Distributed Tracing

Activity represents a unit of work in your application with timing, tags, and parent-child relationships. ASP.NET Core automatically creates an Activity for each HTTP request and propagates trace context to outgoing HTTP calls. This gives you distributed tracing without manual correlation ID management.

Each Activity has a unique ID, a parent ID linking it to the caller, and a trace ID shared across the entire request chain. You can add custom tags and events to capture business context alongside timing data.

ActivityDemo.cs
using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/process", async () =>
{
    var activity = Activity.Current;

    activity?.SetTag("user.id", "user123");
    activity?.SetTag("operation.type", "data-processing");

    activity?.AddEvent(new ActivityEvent("ProcessingStarted"));

    await ProcessDataAsync();

    activity?.AddEvent(new ActivityEvent("ProcessingCompleted"));

    return Results.Ok(new
    {
        traceId = activity?.TraceId.ToString(),
        spanId = activity?.SpanId.ToString(),
        parentId = activity?.ParentId
    });
});

async Task ProcessDataAsync()
{
    using var activity = new Activity("ProcessData");
    activity.Start();

    activity.SetTag("data.size", 1024);
    await Task.Delay(100);

    activity.Stop();
}

app.Run();

Activity.Current gives you the activity for the current async context. The framework creates one for each incoming request, and you can create child activities for internal operations. Tags store metadata like user IDs or operation types, while events mark specific points in time like start and completion milestones.

Creating Custom ActivitySource

ActivitySource is a factory for creating related activities. You create one ActivitySource per library or service component, then use it to start activities for specific operations. This lets observability tools filter traces by source and subscribe only to activities you care about.

Define your ActivitySource as a static field and use it throughout your service. Consumers can listen to activities from specific sources and export them to tracing backends.

OrderService.cs
using System.Diagnostics;

namespace MyApp.Services;

public class OrderService
{
    private static readonly ActivitySource ActivitySource =
        new("MyApp.OrderService", "1.0.0");

    private readonly ILogger _logger;

    public OrderService(ILogger logger)
    {
        _logger = logger;
    }

    public async Task CreateOrderAsync(CreateOrderRequest request)
    {
        using var activity = ActivitySource.StartActivity("CreateOrder");

        activity?.SetTag("order.customerId", request.CustomerId);
        activity?.SetTag("order.itemCount", request.Items.Count);

        _logger.LogInformation(
            "Creating order for customer {CustomerId}",
            request.CustomerId);

        var order = await ValidateAndCreateAsync(request);

        activity?.SetTag("order.id", order.Id);
        activity?.SetTag("order.total", order.Total);

        return order;
    }

    private async Task ValidateAndCreateAsync(CreateOrderRequest request)
    {
        using var activity = ActivitySource.StartActivity("ValidateOrder");

        activity?.AddEvent(new ActivityEvent("ValidationStarted"));

        await Task.Delay(50);

        activity?.AddEvent(new ActivityEvent("ValidationCompleted"));

        return new Order
        {
            Id = Random.Shared.Next(1000),
            CustomerId = request.CustomerId,
            Total = 99.99m
        };
    }
}

public class Order
{
    public int Id { get; set; }
    public int CustomerId { get; set; }
    public decimal Total { get; set; }
}

public class CreateOrderRequest
{
    public int CustomerId { get; set; }
    public List Items { get; set; } = new();
}

StartActivity creates a new activity as a child of Activity.Current. The using statement ensures proper start and stop timing. Tags capture request and response data, making traces searchable in your observability platform. Nested activities create a hierarchy showing which operations happen inside others.

Integrating OpenTelemetry

OpenTelemetry exports your Activity traces to backends like Jaeger, Zipkin, or Application Insights. Configure it in Program.cs to automatically capture ASP.NET Core activities and custom ActivitySource activities. No code changes needed in your services once OpenTelemetry is wired up.

Program.cs
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .AddSource("MyApp.OrderService")
            .SetResourceBuilder(
                ResourceBuilder.CreateDefault()
                    .AddService("MyWebApi", serviceVersion: "1.0.0"))
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddConsoleExporter();
    });

builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
app.Run();

AddAspNetCoreInstrumentation automatically traces incoming requests. AddHttpClientInstrumentation traces outgoing HTTP calls and propagates trace context. AddSource subscribes to your custom ActivitySource. The console exporter outputs traces to stdout for development, and you can swap it for production exporters like OTLP or Jaeger without changing instrumentation code.

Observability Best Practices

Combine tracing with structured logging for full observability. Include trace IDs in logs so you can jump from trace to related log entries. Add tags for key business identifiers like customer ID, order ID, or transaction type. These make traces searchable and help you filter to specific scenarios.

Use events to mark milestones within an activity instead of creating separate activities. Events are lightweight and don't create new spans in the trace. Tag activities with metrics like record counts or payload sizes to understand data volume patterns.

Configure sampling in production to trace only a percentage of requests. Full tracing adds overhead and generates massive data volumes. Sample 1-10% of successful requests but trace all errors to ensure you capture failures while keeping costs reasonable.

Set up dashboards showing P50, P95, and P99 latencies by endpoint and operation. These percentiles reveal performance issues affecting a subset of users. Use trace exemplars to link high-latency metrics directly to example traces showing what went slow.

Production Deployment

In production, export traces to a dedicated observability platform. Configure environment-specific exporters and sampling rates. Enable trace propagation across service boundaries through HTTP headers so downstream services continue the trace.

Program.cs - Production setup
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing
            .AddSource("MyApp.*")
            .SetResourceBuilder(
                ResourceBuilder.CreateDefault()
                    .AddService(builder.Environment.ApplicationName)
                    .AddAttributes(new Dictionary
                    {
                        ["deployment.environment"] = builder.Environment.EnvironmentName,
                        ["service.version"] = "1.0.0"
                    }))
            .AddAspNetCoreInstrumentation(options =>
            {
                options.RecordException = true;
                options.Filter = context =>
                {
                    return !context.Request.Path.StartsWithSegments("/health");
                };
            })
            .AddHttpClientInstrumentation()
            .SetSampler(new TraceIdRatioBasedSampler(0.1));

        if (builder.Environment.IsDevelopment())
        {
            tracing.AddConsoleExporter();
        }
        else
        {
            tracing.AddOtlpExporter(options =>
            {
                options.Endpoint = new Uri(
                    builder.Configuration["OpenTelemetry:Endpoint"]!);
                options.Protocol = OtlpExportProtocol.Grpc;
            });
        }
    });

var app = builder.Build();
app.MapControllers();
app.Run();

This configuration samples 10% of requests and filters out health check endpoints. In development, traces go to console. In production, they export via OTLP to your observability backend. RecordException captures exception details in traces, making failures easier to diagnose. Resource attributes tag all traces with environment and version for filtering across deployments.

Try It Yourself

Build a minimal API with Activity tracing and see traces in the console output.

Steps

  1. Create project: dotnet new web -n TracingDemo
  2. Navigate: cd TracingDemo
  3. Add package: dotnet add package OpenTelemetry.Exporter.Console
  4. Add package: dotnet add package OpenTelemetry.Extensions.Hosting
  5. Add package: dotnet add package OpenTelemetry.Instrumentation.AspNetCore
  6. Replace Program.cs with code below
  7. Run: dotnet run
  8. Test: curl http://localhost:5000/trace
Program.cs
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using System.Diagnostics;

var builder = WebApplication.CreateBuilder(args);

var source = new ActivitySource("TracingDemo");

builder.Services.AddOpenTelemetry()
    .WithTracing(tracing =>
    {
        tracing.AddSource("TracingDemo")
            .SetResourceBuilder(ResourceBuilder.CreateDefault()
                .AddService("TracingDemo"))
            .AddAspNetCoreInstrumentation()
            .AddConsoleExporter();
    });

var app = builder.Build();

app.MapGet("/trace", async () =>
{
    using var activity = source.StartActivity("CustomOperation");
    activity?.SetTag("demo.value", "test");

    await Task.Delay(50);

    return Results.Ok(new
    {
        traceId = Activity.Current?.TraceId.ToString(),
        message = "Trace captured!"
    });
});

app.Run();
TracingDemo.csproj
<Project Sdk="Microsoft.NET.Sdk.Web">
  <PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.7.0" />
    <PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.7.0" />
    <PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.7.1" />
  </ItemGroup>
</Project>

What you'll see

The console shows trace output with activity names, timing, trace IDs, and tags. You'll see the ASP.NET Core request activity and your custom CustomOperation activity nested inside it. The trace ID remains constant across both activities, proving they're part of the same request. Tags appear in the output showing your custom metadata.

Reader Questions

What's the difference between logging and tracing?

Logging records discrete events and messages at points in time. Tracing tracks execution flow across services with timing and correlation. Use logging for errors and key events. Use tracing to understand request paths through distributed systems. They complement each other.

How does Activity integrate with OpenTelemetry?

Activity in .NET maps directly to OpenTelemetry spans. When you create an Activity, OpenTelemetry exporters automatically capture it and send trace data to your observability platform. No additional instrumentation needed for built-in ASP.NET Core operations.

Does tracing slow down my application?

Built-in Activity tracing has minimal overhead when nothing is listening. Enable sampling to trace only a percentage of requests in production. Use ActivitySource filtering to collect traces only for specific operations. The framework optimizes for zero-cost when tracing is disabled.

Back to Articles