ASP.NET Core request metrics: p95 and throughput
The average response time of an API hides the requests that hurt. If 95 requests take 20 ms and 5 take two seconds, the average still looks fine. The 95th percentile (p95) shows the slow ones. You want that number per service, over time, without running Prometheus and Grafana or installing a full APM agent.
This post shows how to get it with ZipLogger.Metrics.AspNetCore: what to install, what the middleware actually sends, where it shows up, and what it does not do.
Install
dotnet add package ZipLogger.Metrics.AspNetCore
The package targets .NET 8 and .NET 10. Register it and add the middleware in Program.cs:
using ZipLogger.Metrics.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddZipLoggerMetrics(builder.Configuration, service: "orders-api");
var app = builder.Build();
app.UseZipLoggerMetrics(); // early, so the timing covers the rest of the pipeline
app.MapGet("/api/orders/{id:int}", (int id) => Results.Ok(new { id, status = "shipped" }));
app.Run();
service is the name the Metrics page shows. If you leave it out, the entry assembly name (lowercased) is used.
The middleware times everything that runs after it. Put it before your other middleware, or the time spent in them is not counted.
Configuration
It reads the same two settings as the Serilog sink, so an app that already ships logs to ZipLogger needs nothing new:
{
"ZipLogger": {
"Endpoint": "https://app.ziplogger.ai",
"ApiKey": "YOUR_API_KEY"
}
}
In production, set the key as the environment variable ZipLogger__ApiKey instead of putting it in the file. The key needs the ingest scope, like any key you use to send logs.
If the endpoint or the key is missing, the middleware does nothing at all. Local runs and tests work without an account and without errors.
Optional settings live under ZipLogger:Metrics:
{
"ZipLogger": {
"Metrics": {
"Service": "orders-api",
"ExcludePaths": [ "/metrics", "/swagger" ]
}
}
}
/health and /favicon.ico are excluded by default. Paths you add are matched by prefix, ignoring case, and are added to those two rather than replacing them. I checked this against a test app: with the setting above, requests to /health and /swagger/... were both left out.
What it sends
I ran the sample above against a small local HTTP server that prints what it receives, then made four requests: two orders, order 404 (which returns Not Found), and a path that does not exist. After about two seconds one batch arrived:
POST /ingest/v1/metrics X-Api-Key=YOUR_API_KEY
[
{
"timestamp": "2026-09-29T11:23:59.8641978+00:00",
"service": "orders-api",
"name": "request.duration",
"durationMs": 9.6916,
"labels": { "method": "GET", "route": "/api/orders/{id:int}", "status": 404 }
},
{
"timestamp": "2026-09-29T11:24:01.1768191+00:00",
"service": "orders-api",
"name": "request.duration",
"durationMs": 0.1998,
"labels": { "method": "GET", "route": "/nope", "status": 404 }
}
]
(Two of the four points shown.) A few things to notice:
routeis the route pattern,/api/orders/{id:int}, not/api/orders/404. That keeps one series per endpoint instead of one per order id.- A request that matched no endpoint uses the raw path.
/nopebecame its own route label. A scanner probing random URLs creates a new label value for every path it tries. More on why that matters below. statusis the response status code, recorded after the rest of the pipeline has run.- The first request after startup took over 300 ms in my run. That is .NET warming up, and it will show in your p95 right after every deploy.
How it behaves under load
Recording a request never blocks it and never throws. Each timing goes into an in-memory queue that holds up to 10,000 entries. When the queue is full, new timings are dropped and counted, rather than slowing requests down. A background task sends batches of up to 100, or whatever has arrived after 2 seconds.
If a send fails with a network error, a 408, a 429 or a 5xx, it is retried up to 3 times with growing delays, and a 429's Retry-After is respected. Any other 4xx (a wrong key, say) drops the batch, because retrying cannot fix it. On a clean shutdown the queue gets 5 seconds to drain.
So metrics can be lost: during an outage longer than the retries, on a crash, or when the queue is full. That is the trade for never slowing a request down. The count of lost timings is DroppedCount on the MetricShipper service if you want to log it.
Where you see it
The Metrics page charts any metric, and it opens on request.duration, the metric this package sends. For the chosen time range, from Last 15 minutes to Last 7 days:
- Aggregation
avgorp95gives the mean or the 95th percentile duration in milliseconds, per time bucket. - Aggregation
countgives how many requests fell in each bucket, which is your throughput. - Group by
servicedraws one line per service. Group by therouteorstatuslabel, or add a filter such asmethod:POST, to look at one part of the traffic.
The table under the chart gives each line's value over the whole range: a real p95 over every point in the range, not an average of bucket values. The query is in the page address, so a chart is a link you can paste, and Add to dashboard… saves it as a Metric widget.
The same data shows up in two other places:
- On the Dashboards page, each service card in the Services overview shows
p95 Xms (1h), the highest 5-minute p95 in the last hour. A service that only sends metrics also counts as seen, so it is not marked silent. - The Response time (p95) and Request volume dashboard widgets chart this data too. The custom dashboards post covers those.
The same points also feed APM, which shows them per endpoint and pairs them with your traces (APM in the docs). When a service sends these points, APM takes its request counts, error rates and latency from them, so those numbers do not depend on how much you sample traces. APM pairs the points with traces by service name. If the service also sends traces, pass the same name as OTEL_SERVICE_NAME. The pairing only works for services that set no service.namespace.
Sending the same data from other stacks
The middleware is a thin client for one endpoint. Anything that can make an HTTP request can post the same shape, one point or an array:
curl -X POST https://app.ziplogger.ai/ingest/v1/metrics \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '[{"service": "billing-worker", "name": "request.duration", "durationMs": 182.4,
"labels": {"method": "POST", "route": "/invoices", "status": 201}}]'
If timestamp is missing, the server uses the time it received the point. If name is missing, it is request.duration. If service is missing, it is unknown. The server answers 202 with accepted and rejected counts, a reasons map for anything refused, a series object with how many distinct series you have opened today against your limit, and a warnings list that fills up as you get close to that limit.
A bad point is refused on its own and counted under reasons; the rest of the batch is stored. Only when nothing in the batch is usable does the whole request get a 400.
Shape limits, the same on every plan: at most 5,000 points and 5 MB per request, 20 label keys per point, label keys up to 64 characters and values up to 256. There are also per-minute and per-day rate limits, and a daily cap on distinct series. A series is the service, the name and the full set of label values. Put a user id, a request id or a raw URL in a label and every request opens a new series, and once the cap is reached, points for new series are refused with cardinality_limit until midnight UTC. Keep labels to things with a handful of values.
What it does not do
- Endpoint views are in APM. The Metrics page can group by the
routelabel, but APM is where the same points become P50, P95, P99 and error rate per endpoint, next to the traces. - Two ways to alert. The APM: P95 latency above N ms and APM: error rate above N % alert conditions use these request metrics when the service sends them (APM alerts). A metric monitor can also watch
request.durationdirectly, for example its p95 for one route. The older API latency increase (traces) condition still reads traces. - Jobs are charted, not analysed. The package can also time background work with
ZipLoggerMetrics.TimeJob("nightly-sweep"), which sendsjob.duration. The Metrics page charts it like any other metric, but APM does not read it. - No release on these points. APM's release views use
service.versionfrom your traces. - No counters, gauges or histograms. Each point is one measurement. Throughput is the number of points, not a counter you increment.
- No CPU, memory or GC metrics. This measures requests, not the process.
To try it, create a free workspace, make an ingest API key, and add the two lines above. The metrics section of the docs has the short version.