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:

  • POST over HTTP/2 to one fixed path per signal.

  • content-type: application/grpc+proto and te: 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, then DATA, then a second HEADERS carrying grpc-status.

  • The response headers, for a Trailers-Only response. A server that fails before producing a body sends one HEADERS frame with :status, the grpc-status and END_STREAM, and no DATA at all - nghttp2 reports that first frame as HCAT_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)