APM for ASP.NET Core Minimal APIs: OpenTelemetry + Endpoint Filters
EngineeringAugust 10, 202612 min read

APM for ASP.NET Core Minimal APIs: OpenTelemetry + Endpoint Filters

APM for .NET 8 Minimal APIs — trace slow endpoints in under an hour.

The endpoint was slow. That much I knew. The /api/orders/{id} route was taking 800ms on average, spiking past 2 seconds during peak hours. I had logging. I had metrics. What I didn't have was any idea where those 800 milliseconds were going.

Was it the database query? The JSON serialization? Some middleware I forgot I added six months ago? I spent a full day adding manual Stopwatch calls, deploying, checking logs, rinse and repeat. By 5pm I'd narrowed it down to "somewhere between the request hitting Kestrel and the response leaving." Real helpful.

That's when I finally set up proper APM for ASP.NET Core Minimal APIs. Took about an hour. Should've done it on day one.

This tutorial walks through adding APM to a .NET 8 Minimal API using OpenTelemetry's auto-instrumentation, custom endpoint filter spans, and EF Core query tracing — all feeding into JustAnalytics for P95/P99 visibility. By the end, you'll see exactly where your time goes on every request.

What you'll have by the end

A Minimal API project with:

  • Automatic span creation for every HTTP request (method, route, status code, duration)
  • EF Core query tracing showing SQL execution time as child spans
  • Custom endpoint filter spans for fine-grained timing (validation, authorization, business logic)
  • All traces exported to JustAnalytics via OTLP, visible in a service map

The whole setup is about 60 lines of configuration code. Most of it is NuGet packages doing the heavy lifting.

Prerequisites

  • .NET 8 SDK (tested on 8.0.7)
  • A Minimal API project — if you're starting fresh, dotnet new webapi -minimal works
  • Entity Framework Core if you want database tracing (we'll use SQL Server, but the pattern works with Postgres, SQLite, etc.)
  • A JustAnalytics account — free tier includes 100K events/month, which covers most dev/staging workloads

If you're running Blazor alongside your APIs, our Blazor monitoring tutorial covers the frontend side. The traces connect — a Blazor Server request that calls your Minimal API shows up as a single distributed trace.

Step 1: Install the OpenTelemetry packages

You'll need a handful of packages. This is more than it looks — OTel splits functionality across multiple libraries so you only pay for what you use.

dotnet add package OpenTelemetry.Extensions.Hosting
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
dotnet add package OpenTelemetry.Instrumentation.Http
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol

That's five packages. Yeah, five. The OTel ecosystem loves splitting things into tiny single-purpose libraries. Annoying when you're adding dependencies, but honestly? I've come around to it. You only pay for what you use. If you're managing dependencies across multiple services, DevOS can help standardize these configurations.

  • Extensions.Hosting wires OTel into the ASP.NET Core host
  • Instrumentation.AspNetCore auto-instruments incoming HTTP requests
  • Instrumentation.Http auto-instruments outgoing HttpClient calls
  • Instrumentation.EntityFrameworkCore captures EF Core queries as spans
  • Exporter.OpenTelemetryProtocol sends traces via OTLP (the OpenTelemetry standard protocol)

Skip the EF Core package if you're not using Entity Framework. The other four are non-negotiable.

Step 2: Configure OpenTelemetry in Program.cs

Open your Program.cs and add the OTel configuration. I'm putting this right after var builder = WebApplication.CreateBuilder(args); — before any middleware or endpoint registration.

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

var builder = WebApplication.CreateBuilder(args);

// OpenTelemetry configuration
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource
        .AddService(
            serviceName: "orders-api",
            serviceVersion: "1.0.0"))
    .WithTracing(tracing => tracing
        .AddAspNetCoreInstrumentation(options =>
        {
            // Include query strings in the span name for debugging
            options.RecordException = true;
            options.EnrichWithHttpRequest = (activity, request) =>
            {
                activity.SetTag("http.client_ip",
                    request.HttpContext.Connection.RemoteIpAddress?.ToString());
            };
        })
        .AddHttpClientInstrumentation()
        .AddEntityFrameworkCoreInstrumentation(options =>
        {
            options.SetDbStatementForText = true; // Include SQL text
        })
        .AddOtlpExporter(options =>
        {
            options.Endpoint = new Uri(
                builder.Configuration["JustAnalytics:OtlpEndpoint"]
                ?? "https://otlp.justanalytics.app/v1/traces");
            options.Headers = $"Authorization=Bearer {builder.Configuration["JustAnalytics:ApiKey"]}";
        }));

// ... rest of your service registration

The AddService call sets your service name in the trace data. Pick something descriptive — "orders-api" beats "my-api" when you're looking at a service map with 12 services on it.

That EnrichWithHttpRequest callback adds the client IP to each span. Useful for debugging "this one customer is slow" issues. You can add whatever custom tags make sense for your domain.

Step 3: Add your config values

Drop these in appsettings.json or your preferred config source:

{
  "JustAnalytics": {
    "OtlpEndpoint": "https://otlp.justanalytics.app/v1/traces",
    "ApiKey": "your-api-key-here"
  }
}

For production, pull these from environment variables or a secrets manager. The pattern stays the same.

At this point, deploy and hit a few endpoints. Open your JustAnalytics dashboard, navigate to APM, and you should see traces appearing within a minute or two. Each request shows up as a span tree: the HTTP request at the root, any EF Core queries as children, any outgoing HTTP calls as siblings.

That's the 80% solution. Auto-instrumentation catches the framework-level stuff automatically.

Step 4: Add endpoint filter spans for custom timing

Auto-instrumentation is great for "where did the time go at a high level." But what if your endpoint does five things and you want to know which one is slow? That's where endpoint filters come in.

Minimal APIs support endpoint filters — middleware-like functions that wrap specific endpoints. We'll use them to add custom spans around business logic.

First, create a filter that wraps any endpoint with a named span:

// Filters/TimingFilter.cs
using System.Diagnostics;
using OpenTelemetry;

public class TimingFilter : IEndpointFilter
{
    private static readonly ActivitySource ActivitySource =
        new("orders-api.endpoints");

    private readonly string _spanName;

    public TimingFilter(string spanName)
    {
        _spanName = spanName;
    }

    public async ValueTask<object?> InvokeAsync(
        EndpointFilterInvocationContext context,
        EndpointFilterDelegate next)
    {
        using var activity = ActivitySource.StartActivity(_spanName);

        try
        {
            var result = await next(context);
            activity?.SetStatus(ActivityStatusCode.Ok);
            return result;
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.RecordException(ex);
            throw;
        }
    }
}

Register the ActivitySource with OpenTelemetry so your custom spans get exported:

// In Program.cs, update the tracing config
.WithTracing(tracing => tracing
    .AddSource("orders-api.endpoints") // Add this line
    .AddAspNetCoreInstrumentation(/* ... */)
    // ... rest stays the same

Now apply the filter to specific endpoints:

app.MapGet("/api/orders/{id}", async (int id, OrderService orderService) =>
{
    var order = await orderService.GetOrderAsync(id);
    return order is null ? Results.NotFound() : Results.Ok(order);
})
.AddEndpointFilter(new TimingFilter("GetOrder.FetchFromDatabase"));

You can stack multiple filters for different phases:

app.MapPost("/api/orders", async (CreateOrderRequest request, OrderService orderService) =>
{
    var order = await orderService.CreateOrderAsync(request);
    return Results.Created($"/api/orders/{order.Id}", order);
})
.AddEndpointFilter(new TimingFilter("CreateOrder.Validation"))
.AddEndpointFilter(new TimingFilter("CreateOrder.Persist"))
.AddEndpointFilter(new TimingFilter("CreateOrder.Notify"));

Each filter adds a child span. The trace now shows exactly how long validation took versus persistence versus whatever notification you're firing. When "CreateOrder" is slow, you'll see which phase is eating the time.

Step 5: EF Core query latency deep dive

The auto-instrumentation captures EF Core queries as spans, but sometimes you want more context. Like which entity was being queried, or how many rows came back.

You can enrich EF Core spans with a diagnostic listener. This is more advanced, but worth it if database performance is your bottleneck.

// Diagnostics/EfCoreDiagnosticListener.cs
using Microsoft.EntityFrameworkCore.Diagnostics;
using System.Diagnostics;

public class EfCoreDiagnosticListener : IObserver<DiagnosticListener>
{
    public void OnNext(DiagnosticListener listener)
    {
        if (listener.Name == "Microsoft.EntityFrameworkCore")
        {
            listener.Subscribe(new EfCoreEventObserver());
        }
    }

    public void OnCompleted() { }
    public void OnError(Exception error) { }
}

public class EfCoreEventObserver : IObserver<KeyValuePair<string, object?>>
{
    public void OnNext(KeyValuePair<string, object?> pair)
    {
        if (pair.Key == "Microsoft.EntityFrameworkCore.Database.Command.CommandExecuted")
        {
            var activity = Activity.Current;
            if (activity != null && pair.Value is CommandExecutedEventData data)
            {
                activity.SetTag("db.rows_affected", data.Command.Parameters.Count);
                activity.SetTag("db.duration_ms", data.Duration.TotalMilliseconds);
            }
        }
    }

    public void OnCompleted() { }
    public void OnError(Exception error) { }
}

Register it in Program.cs:

DiagnosticListener.AllListeners.Subscribe(new EfCoreDiagnosticListener());

Now your EF Core spans include row counts and precise query duration. When you're hunting for that one query that returns 50,000 rows when it should return 50, this is how you find it.

(Ask me how I know. Go on. Ask me. I absolutely did not spend two weeks blaming the network for a missing .Where() clause.)

Common errors and how to fix them

"No traces appearing in the dashboard." Check three things: (1) your API key is correct, (2) the OTLP endpoint URL doesn't have a trailing slash when it shouldn't, (3) your firewall allows outbound HTTPS to otlp.justanalytics.app. The OTel exporter fails silently by default — add logging to the exporter config if you're debugging: .AddOtlpExporter(options => { options.ExportProcessorType = ExportProcessorType.Simple; /* ... */ }) and check your console output. See our uptime monitoring setup guide for more on network connectivity troubleshooting.

"Spans are missing child relationships." Your ActivitySource name doesn't match what you registered with .AddSource(). The names must be identical. I've typo'd this more times than I'd like to admit.

"EF Core queries show but no SQL text." You didn't set options.SetDbStatementForText = true in the EF Core instrumentation config. Or you're using a version of the package older than 1.5.0 — check your dependencies.

"Traces appear but the service map is empty." The service map needs multiple services with cross-service calls to display meaningfully. For a single API, you'll see one node. Add HttpClient instrumentation for outgoing calls to other services, and they'll appear.

"Why is this so much configuration?" I feel you. Genuinely. The OTel .NET SDK is powerful but not exactly plug-and-play. The silver lining: you set this up once. Copy-paste into your next project. I keep a Gist with my baseline config that I've used across six services now. For session-level debugging with user context, check out our session replay feature.

What you're looking at in the dashboard

Once traces are flowing, here's how to actually use them.

The APM overview shows your endpoints ranked by P95 latency. Sort by it. The slow ones bubble up. Click into a slow endpoint and you get a waterfall view of recent traces. Each trace is a tree of spans.

For that /api/orders/{id} endpoint that started this whole adventure? My waterfall showed three EF Core queries. Two were fast — 4ms each. The third was a 650ms monster loading order line items without pagination. One Take(100) later and P95 dropped to 120ms. If you're comparing APM tools, the ability to drill from latency percentile to specific slow query is the difference between useful and decorative.

Look for patterns: endpoints that are consistently slow (optimize them), endpoints with high variance (something's inconsistent — check cache hit rates or connection pooling), endpoints where child spans don't add up to parent span time (you've got untraced work, add more filters).

Here's my unpopular opinion: most slow API problems are database problems. Not serialization. Not middleware. Not network. Database. The trace waterfall will prove me right about 80% of the time. Start there.

Next steps

You've got baseline APM. The obvious extensions:

Add traces to background jobs. If you're using Hangfire or Quartz, create an ActivitySource for your job runner and wrap job execution in a span. Jobs often hit the same bottlenecks as HTTP endpoints, but without tracing they're invisible.

Set up latency alerts. JustAnalytics Pro ($49/month, $39/month billed annually) includes alerting on P95/P99 thresholds. When that one endpoint regresses from 100ms to 500ms after a deploy, you'll know before customers complain. The free tier gives you the traces; Pro gives you proactive notification.

Correlate with errors. APM traces and error tracking feed into the same dashboard. An exception span links to its error report. When a request is slow and throws, you see both together. We've covered this correlation pattern in our error-analytics correlation guide.

For teams managing multiple .NET services, DevOS helps standardize observability config across repositories — same OTel setup, same exporter config, one PR to update all services. And if your APIs are behind ad-driven traffic, ClickzProtect pairs APM data with click fraud detection: when a traffic source suddenly sends 10x more requests that all time out, you've got a pattern worth investigating.

The full code from this tutorial is in our examples repo. Fork it, break it, ship it.

Frequently Asked Questions

Does this work with .NET 9 and the new Minimal API improvements?

Yes. The OpenTelemetry SDK targets .NET Standard 2.0+, so it works on .NET 8, 9, and future versions. The endpoint filter pattern we use is stable API — Microsoft hasn't signaled any breaking changes. If you're on .NET 9 preview builds, you might see minor namespace shuffles in the OTel packages, but the core wiring stays identical.

Can I use this alongside Application Insights without conflicts?

You can, but you probably shouldn't. Both will hook into the same DiagnosticSource events, and you'll get duplicate traces with slightly different timestamps. If you need App Insights for Azure-specific diagnostics, use the OpenTelemetry exporter for Azure Monitor instead of running both SDKs. One pipeline, two destinations.

How much latency does the OTel instrumentation add to my endpoints?

In our benchmarks on a Minimal API doing simple CRUD, the overhead was 0.3-0.8ms per request at P99. The auto-instrumentation is designed to be cheap — it's the same infrastructure Datadog and Honeycomb use. If you're chasing sub-millisecond response times, you'll notice. For most APIs with 10ms+ response times, it's noise.

Will EF Core query spans include the actual SQL text?

By default, yes — the OpenTelemetry.Instrumentation.EntityFrameworkCore package captures the SQL command text as a span attribute. You can disable this with a config option if you're worried about sensitive data in your traces. We recommend leaving it on for dev/staging and evaluating for production based on your data sensitivity.


Try JustAnalytics

All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).

Start free → · AI Command Center MCP

JP
JustAnalytics Platform TeamContributor

Author at JustAnalytics.

Related posts