NAME
Punk::Plugin::RequestId - give every request an id
SYNOPSIS
package MyApp;
use Punk;
plugin 'RequestId';
get '/' => sub {
my ($c) = @_;
$c->log->info('serving the front page'); # carries the id
$c->text($c->request_id);
};
Turn the response header off, or rename it:
plugin 'RequestId' => { header => 0 };
plugin 'RequestId' => { header => 'X-Correlation-Id' };
DESCRIPTION
Every request gets an id. It is readable as $c->request_id, returned to the client as X-Request-Id, and - once you are past this plugin's own phase - attached to every log line the request produces.
The point is the join: a user quotes the id their browser showed them, and that one string finds every line the request wrote.
Every response, not just the ones that matched a route
The id is minted before routing, so a request that 404s, one that 405s, one answered by a static file and one answered by a mounted PSGI app all have one. That is deliberate: a response with no id is one nobody can trace, and the untraceable ones are disproportionately the ones somebody is trying to trace.
In the log
Every line a request writes carries the id, with nothing passed at the call site:
$c->log->info('listing books');
# [2026-08-20T15:38:29Z] [info] GET / - listing books request_id=01a01f...
It is a field, not a new position in the line. The shape up to the message is unchanged - [time] [level] METHOD /path - message - so anything already splitting on that prefix keeps working, and the id joins the fields a record was always allowed to add after it. Under format => 'json' it is a request_id key.
The id reaches the logger through psgix.request_id, the PSGI extension key, so a middleware or a mounted app in the same stack sees the same value.
A line logged outside a request - at worker startup, say - carries no id rather than the last one this worker served.
The id
A UUIDv7 (RFC 9562) as 32 lowercase hex characters: 48 bits of millisecond timestamp followed by randomness.
01a01fa76d557485932fd12163f36cb6
OPTIONS
trust_header-
Adopt an id handed in by a proxy instead of minting a fresh one. Off by default, and deliberately so.
plugin 'RequestId' => { trust_header => 1 };Punk sits behind a proxy in production and on
localhostin development, and in the second case the header is whatever the client felt like sending. A default that trusted it would be a default that writes attacker-chosen bytes into the log of every application that copied the synopsis.Turn it on only where something in front of you sets the header and strips whatever the client sent.
An adopted value must be 1 to 128 bytes of printable ASCII with no space (
0x21to0x7e). That covers a UUID, hex, base64, base64url and a W3C traceparent, and excludes CR, LF, NUL, tab, every other control byte and everything above ASCII - because the value reaches a log line and a response header, where a CR forges an entry in the first and splits the second.A value that fails is replaced by a fresh id, never trimmed into shape: a mangled id correlates with nothing at either end, and silently editing what a client sent produces a third value matching neither. Refusals are counted - see "stats".
Using it with
header => 0croaks at boot, since there would be no header to read. header-
The response header carrying the id,
X-Request-Idby default. Pass a different name to rename it, or a false value to send no header at all - the id is still minted, still on$c->request_id, and still in the logs.A name containing anything outside
[A-Za-z0-9_-]croaks at boot rather than writing a malformed header.
METHODS
request_id
my $id = $c->request_id;
The current request's id. A real method on the context, installed once at to_app.
stats
my %s = Punk::Plugin::RequestId->stats;
# ( minted => 1200, adopted => 340, rejected => 2 )
Process-wide counts. adopted and rejected stay at zero unless trust_header is on, and a rising rejected is somebody sending ids that are not ids - which is worth being able to see rather than discarding silently.
CAVEATS
By default an inbound X-Request-Id is ignored and every request gets a fresh id. See trust_header above for what it takes to change that, and why it is not the default.
SEE ALSO
AUTHOR
LNATION <email@lnation.org>
LICENSE AND COPYRIGHT
This software is Copyright (c) 2026 by LNATION <email@lnation.org>.
This is free software, licensed under:
The Artistic License 2.0 (GPL Compatible)