NAME
Punk::Plugin::Metrics - a Prometheus endpoint whose labels cannot run away
SYNOPSIS
package MyApp;
use Punk;
plugin 'Metrics';
get '/users/:id' => sub { $_[0]->text('user') };
# GET /metrics
# http_requests_total{method="GET",route="/users/:id",status="200",worker="4812"} 50
# http_request_duration_seconds_bucket{method="GET",route="/users/:id",worker="4812",le="0.0005"} 50
# http_requests_in_flight{worker="4812"} 0
DESCRIPTION
Why, given Punk::OpenTelemetry exists
They are not substitutes, and the difference is push against pull. OpenTelemetry pushes OTLP to a collector somebody has to run. Prometheus scrapes an endpoint, and a scrape needs nothing deployed beside the application - which for a great many deployments is the difference between "metrics exist" and "metrics were a project".
They coexist. The same request feeds both, and an application that later adopts a collector keeps its dashboards.
Cardinality, which is the thing most exporters get wrong
A counter labelled with the request path is the classic monitoring outage: /users/1, /users/2 and a million more each become their own time series, and the scrape target eventually takes the monitoring system down with it.
Punk cannot make that mistake, because the compiled route table is the label set. Every route pattern is known at to_app, the set is bounded before a single request arrives, and /users/:id is one series however many ids exist. That is a real dividend of compiling routes at boot rather than matching them one at a time.
Anything with no route to name is labelled once as <other>. A 404 has no route by definition, and giving it the request path is precisely how the bounded dimension becomes unbounded again. An api mount is labelled with its OpenAPI operationId, which is bounded the same way.
A scrape hits one worker
This is the trap specific to a prefork server, and it has to be said out loud rather than discovered from a graph.
Whichever worker the listener hands the scrape connection to answers with that worker's counters. A naive exporter therefore reports one Nth of the traffic, and a different Nth every scrape - every graph wrong in a way that looks like noise.
There are two ways out. Aggregate through the shared memory arena, which is correct and costs an atomic per request on a contended cacheline - against a request path of about 4.4 microseconds, that is a cost to measure before committing to. Or export per-worker series and let Prometheus add them up.
This does the second. Every series carries a worker label holding the pid, and the query side pays for it:
sum by (route, status) (rate(http_requests_total[5m]))
The cost is series count multiplied by worker count. The benefit is no shared state, no contention on the request path, and numbers that are true.
The buckets
0.0005 0.001 0.0025 0.005 0.01 0.025 0.05 0.1 0.25 0.5 1 2.5 5 10
Chosen once and documented, because changing them later invalidates every histogram already recorded.
They are not Prometheus's defaults, which start at 5ms. Punk dispatches a request in microseconds, so a first bucket of 5ms would hold essentially every request and the histogram would answer no question anyone asks. These start at half a millisecond and keep the familiar tail.
Punk::Plugin::Metrics->buckets returns them.
The scrape does not count itself
A /metrics series whose only traffic is Prometheus asking about it is noise in every panel, so the scrape's own request is excluded - from the counters and from http_requests_in_flight.
The in-flight half matters more than it looks: a scrape is itself in flight while it renders, so counting it would give an idle server a permanent floor of one, on every dashboard, for ever.
WHAT IT EXPORTS
http_requests_total{method,route,status,worker} counter
http_request_duration_seconds{method,route,worker} histogram
http_requests_in_flight{worker} gauge
punk_cache_*{cache,worker} gauge
The cache gauges are whatever Punk::Cache reports from stats - hits, misses, evictions, entries, bytes, and pool, which says whether a shared invalidation bus exists at all. They appear only when a cache is configured, and every configured cache is reported, labelled with its name - the label set is bounded by the configuration, which is the rule the route label follows too.
status is - for a streaming or detached response, which has no status to read. A guess would put a 200 on a graph that never happened.
Everything else goes through collect
plugin 'Metrics' => {
collect => sub {
return {
queue_depth => MyApp->queue->depth,
queue_workers => MyApp->queue->workers,
};
},
};
Values become gauges, labelled with the worker pid.
A server's own counters and a queue's depth are deliberately not read from here. Reaching into another distribution would couple this plugin to versions of things it cannot test against, and the application already has both in scope. collect is the seam.
Two guards on it, both because a scrape is all-or-nothing:
A name Prometheus will not accept is dropped. One invalid name does not break one series - it makes the whole document unparseable, and every metric the application has vanishes at once.
A callback that dies is skipped and the scrape continues. Losing an optional gauge must not lose the core metrics with it.
OPTIONS
path
Where to serve, defaulting to /metrics. The route is excluded from Punk::Plugin::Sitemap: a scrape target is not a page.
collect
A coderef returning a hashref of name => number. See above.
METHODS
Punk::Plugin::Metrics->buckets
The histogram bucket boundaries, in seconds.
CAVEATS
The counters are per process, not per application. Two Punk applications in one process share them, and the worker label is the pid, so what a scrape describes is the worker rather than the application. That is what a per-worker exporter means, and it is the same thing the worker label is telling you.
They are not persistent. A restarted worker starts from zero. Prometheus handles counter resets; this is only worth knowing when reading a raw scrape.
SEE ALSO
Punk, Punk::Plugin::Health, Punk::OpenTelemetry
AUTHOR
LNATION, <email at lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION.
This is free software, licensed under the Artistic License 2.0.