NAME
Punk::OpenTelemetry::GRPC - OTLP over gRPC
DESCRIPTION
gRPC is not a framework here and is not a dependency. OTLP/gRPC is a narrow, fixed use of HTTP/2:
POSTover HTTP/2 to one fixed path per signal.content-type: application/grpc+protoandte: trailers.The body is length-prefixed frames: one byte compressed flag, four bytes big-endian length, then the same protobuf message the HTTP transport sends. gRPC changes the framing and the status channel, not the message.
The response is the same framing, and then the part that catches people out.
THE STATUS IS IN THE TRAILERS
A gRPC call returns HTTP 200 even when it fails. The real outcome is in the HTTP/2 trailing headers: grpc-status (a number) and grpc-message.
A transport that reads the HTTP status and stops will report every failure as a success. That is worse than not implementing gRPC at all, because it looks like it works: telemetry vanishes and every indicator stays green.
For the same reason, a missing grpc-status is not success. It means the stream ended without the server saying how it went - a transport failure, and retryable. verdict treats it that way.
Retryable codes
CANCELLED, DEADLINE_EXCEEDED, ABORTED, OUT_OF_RANGE, UNAVAILABLE and DATA_LOSS. Everything else is permanent.
RESOURCE_EXHAUSTED is the exception: retryable only when the server sent RetryInfo in grpc-status-details-bin. Without it the server is refusing a quota, and retrying a quota refusal on a timer is how a client turns its own rate limit into an outage.
Compression
gzip lives in the frame flag and in grpc-encoding - not in HTTP's content-encoding. Two mechanisms with similar names, and using the HTTP one produces a request the collector rejects with a confusing message.
Ports
4317 for gRPC, 4318 for HTTP. Getting this wrong is the single most common OTLP misconfiguration, which is why default_port states it rather than leaving it to a default somewhere else.
WHERE THE STATUS IS FOUND
Two places, and both are normal.
The trailers, for an ordinary call:
HEADERS, thenDATA, then a secondHEADERScarryinggrpc-status.The response headers, for a Trailers-Only response. A server that fails before producing a body sends one
HEADERSframe with:status, thegrpc-statusandEND_STREAM, and noDATAat all - nghttp2 reports that first frame asHCAT_RESPONSE, so the status lands in the ordinary header list. A client that looked only at trailers would find no status on exactly the responses that failed fastest.
classify looks in the trailers first and then the headers, so a trailer wins over a stale header of the same name.
REQUIREMENTS
Fetch 0.15 or newer, which captures HTTP/2 trailers. Before it, Fetch's header callback filtered to NGHTTP2_HCAT_RESPONSE and discarded them, so there was no grpc-status to read and a client could only ever report success.
Fetch's HTTP/2 is compiled only when libnghttp2 is present; without it there is no HTTP/2 for gRPC to sit on.
Streaming request bodies are deliberately not needed: OTLP uses unary calls, so one frame goes out and one comes back, which removes the hardest part of a general gRPC client.
FUNCTIONS
None are exported.
path($signal)
The fixed service path. Part of the protocol, not configuration.
frame($message, $compressed)
The message with its five-byte prefix. The length is big-endian, unlike every other length in this distribution.
unframe($buffer)
($body, $compressed, $consumed), or an empty list when the buffer does not hold a whole frame yet - which means "not yet" and is not a failure. A length that would run past the buffer is refused rather than trusted, because it arrived over a network.
retryable($code, $has_retry_info)
Whether a gRPC status code may be retried. RESOURCE_EXHAUSTED is the odd one and needs the second argument: it is retryable only when the server sent RetryInfo in the details. Without it the server is refusing a quota, and retrying a quota refusal on a timer is how a client turns its own rate limit into an outage.
verdict($have_status, $code, $has_retry_info)
0 ok, 1 retry, 2 permanent - the same verdicts the HTTP transport uses, so a caller branches once.
retry_after($details)
The delay a server named in grpc-status-details-bin, in seconds, or undef. When the server names one it wins over any computed backoff: it knows when it will be ready and the client does not.
default_port($protocol) / headers($compressed)
default_port is 4317 for grpc and 4318 for anything else. Stated here rather than left implicit because pointing a gRPC exporter at the HTTP port is the most common OTLP misconfiguration there is.
headers is the header list a gRPC request must carry: content-type: application/grpc+proto and te: trailers, plus grpc-encoding: gzip when compressed. te: trailers is not optional - it is how a client tells the server it will read the trailing metadata, and "THE STATUS IS IN THE TRAILERS" is where the status lives.
classify($res)
($verdict, $code, $message, $retry_after) from a Fetch::Response. See "WHERE THE STATUS IS FOUND".
send($ua, $endpoint, $signal, $bytes, $timeout)
Frame the payload and POST it to the signal's service path, returning the agent's future. $endpoint is a scheme, host and port with no path - the path is protocol, not configuration.
SEE ALSO
Punk::OpenTelemetry::Exporter, the same job over HTTP, and Punk::OpenTelemetry::Config for the OTEL_EXPORTER_OTLP_PROTOCOL setting that chooses between them.
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)