Observability
Four batteries, from "which log line was that request" to full
distributed tracing. They coexist - the same request can feed all of
them - and each is one plugin line.
Request ids
plugin 'RequestId';
Every request gets an id: readable as $c->request_id, returned as
X-Request-Id, and attached to every log line the request produces
with nothing passed at the call site:
$c->log->info('listing books');
# [2026-08-20T15:38:29Z] [info] GET / - listing books request_id=01a01f...
The point is the join: a user quotes the id their browser showed them,
and that one string finds every line the request wrote. The id is
minted before routing, so a 404, a static file and a mounted PSGI app
all carry one - a response with no id is one nobody can trace, and the
untraceable ones are disproportionately the ones somebody is trying to
trace. It travels as psgix.request_id, so middleware and mounted apps
in the same stack see the same value. Options: header => 0 to drop
the response header, or a name to rename it.
Metrics
plugin 'Metrics';
# GET /metrics
# http_requests_total{method="GET",route="/users/:id",status="200",worker="4812"} 50
Punk::Plugin::Metrics
is a Prometheus endpoint whose labels cannot run away. 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. Punk cannot make that mistake, because the compiled route table
is the label set: /users/:id is one series however many ids exist,
bounded before a single request arrives. A request with no route to
name is labelled once as <other>, and an api mount is labelled by
its OpenAPI operationId.
The prefork trap, said out loud: a scrape hits one worker and gets that
worker's counters. So every series carries a worker label and the
query side adds them up -
sum by (route, status) (rate(http_requests_total[5m]))
- which costs series-count times workers and buys no shared state, no contention on the request path, and numbers that are true.
Health probes
plugin 'Health' => {
version => $ENV{APP_VERSION},
checks => {
db => sub { $_[0]->model('User')->backend->dbh->do('SELECT 1'); 1 },
},
};
Punk::Plugin::Health
serves /healthz and /readyz, and the distinction is the whole
plugin. /healthz is liveness - is this process wedged - and runs no
checks at all, because failing a liveness probe gets the worker
killed, and restarting a worker does not fix a database: one slow
dependency would restart the whole fleet in a loop, a much worse outage
than the one that started it. /readyz is readiness - should this
worker be sent traffic - and is where dependencies belong: failing it
answers 503, which takes the worker out of the pool without killing
it, and it returns when the dependency does.
A timeout bounds a readiness pass by refusing to start checks once
the budget is spent, reporting them skipped and answering unready.
Bodies are {"status":"ok"} and nothing else unless detail => 1 -
check names and timings are topology, and probe endpoints are often
reachable.
OpenTelemetry
Punk::OpenTelemetry is the full story - server, client and database spans, the metrics the HTTP conventions ask for, and log records correlated by trace id:
use Punk::Plugin::OpenTelemetry;
otel service_name => 'checkout',
endpoint => 'http://collector:4318';
plugin 'OpenTelemetry';
Or entirely from the environment, with no code at all:
OTEL_SERVICE_NAME=checkout \
OTEL_EXPORTER_OTLP_ENDPOINT=http://collector:4318 \
plackup -s Hyperman app.psgi
Configuration layers as the spec asks: the otel keyword over a
punk.yml otel: block over the OTEL_* environment. The
instrumentation goes through C ABI observer tables in Punk,
Fetch and
DBIx::Loop, so an instrumented
request pays no Perl frame for being instrumented and an unsampled one
allocates nothing at all. One sharp edge worth knowing: there is no
default endpoint - set none and spans are recorded and dropped, not
sent to localhost.
Why both Metrics and OpenTelemetry exist: push against pull.
OpenTelemetry pushes OTLP to a collector somebody has to run;
Prometheus scrapes an endpoint and needs nothing deployed beside the
application - for many deployments the difference between "metrics
exist" and "metrics were a project". An application that starts with
/metrics and later adopts a collector keeps its dashboards.
The queue has its own eyes
Punk::Queue's Funky admin UI is the observability
surface for background work - queues, jobs, retries, workers - and the
punk doctor command reports versions and C ABI wiring when something
looks miswired at boot.