Bilis
Features Pricing Docs Blog

Blog Samuel Vrablik

Bilis decodes OTLP protobuf without a protobuf library

Why the binary OTLP endpoint is a few hundred lines of hand-written PHP instead of a composer package or a PECL extension, and what makes that a safe thing to hand-write.

Point an OpenTelemetry collector at Bilis and, unless you have gone out of your way to configure otherwise, it will send application/x-protobuf. That is the default encoding for OTLP/HTTP, so supporting it is not optional — an endpoint that only speaks OTLP/JSON is an endpoint most exporters cannot use.

The usual way to accept it is ext-protobuf or a generated PHP class library. Bilis has neither. The binary path is a hand-written wire-format reader and one decoder that knows the OTLP message shapes: two files, under eight hundred lines including their comments, no new dependency.

Why not just install something

Because of what it would cost the person running Bilis. The whole pitch is one Laravel app and one ClickHouse — a thing you can deploy on a box you already have. A PECL extension moves a compile step into everyone's Dockerfile and turns a PHP upgrade into a rebuild. A generated class library brings tens of thousands of lines of code you did not write into the one place in the application that a stranger on the internet gets to send bytes to.

For a general protobuf problem that trade is usually worth it. This is not a general protobuf problem. Bilis reads exactly one message — ExportLogsServiceRequest — and only ever converts it into the same array that the JSON path already produces. That is a small, closed, permanently stable target, and the cost of owning it is a few hundred lines.

The wire format is genuinely small

Protobuf's encoding has five wire types. Two of them are the deprecated group encoding, which the reader rejects outright. That leaves varints, length-delimited chunks and the fixed-width types. Every read is bounds-checked against the end of the reader's window, and anything it cannot represent is refused rather than guessed at.

The field numbers come from the .proto definitions, and the protobuf compatibility rules are what make hand-writing this safe: a field number never changes meaning. Each private method in the decoder corresponds to one protobuf message, takes a reader over that message's bytes, and loops over tags with a match on the field number and a skip() default — so an unknown field from a newer collector is skipped, never an error.

One rule keeps the rest of the code honest

The decoder's output is the array json_decode would have returned for the same export sent as OTLP/JSON: camelCase keys, nanosecond timestamps as digit strings, hex trace and span ids, base64 for byte values. Nothing downstream ever learns which encoding arrived. The mapper that turns an OTLP record into a ClickHouse row has exactly one input shape.

That is a property you can test rather than a convention you have to remember. The fixtures are captured from a real Go otlploghttp exporter — the same export in both encodings — and the test asserts the two produce identical rows. If the decoder ever drifts, it fails against bytes a real collector actually sent.

Two things that are easy to get wrong

Submessages are windows, not copies. A reader is a range over a shared buffer, and reading a nested message returns a child reader over a sub-range of that same buffer. The obvious implementation — new Reader(substr(...)) at each level — re-copies everything below every wrapping level, and a body nested deep enough turns fifteen megabytes of request into hundreds of megabytes of memory. Only leaf scalars are copied out, once each. PHP strings are copy-on-write, so sharing the buffer costs a refcount.

A protobuf string is not a JSON string. On the wire it is raw bytes with no UTF-8 guarantee. Carry an ill-formed sequence through to json_encode and it throws, which surfaces as a 503 that fails the entire batch and sends the exporter into a retry loop — one bad byte costing every good line in the request. So every field that becomes a JSON string is scrubbed on the way through. The JSON endpoint never meets this problem, because json_decode rejects bad UTF-8 at the door.

Both of these are the sort of thing a library would have handled for you. That is the honest cost of the decision, and it is why the hostile cases have tests and the reasons are written down in the files themselves.

The escape hatches

BILIS_OTLP_PROTOBUF=false turns the binary path off, and the endpoint goes back to answering 415 while naming the encoding it does support. A body that will not decode at all is treated the way every other malformed payload is: skipped and counted, never a 400. Ingest still does not blame the client.

OTLP over gRPC remains out of scope, and that one is not stubbornness — PHP is a poor gRPC server, and pretending otherwise would be a worse answer than documenting the HTTP port clearly.

All posts Atom feed