feat: support OTLP/JSON on function stdout - #176
Open
lizthegrey wants to merge 15 commits into
Open
Conversation
The layer zip that Lambda downloads at cold start was carrying a symbol table and debug info we never use in production. Dropping them takes the zipped x86_64 layer from 7.1 MiB to 3.8 MiB. Panic stack traces are unaffected, since Go builds those from the runtime's pclntab. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recognizes an OTLP/JSON export request by its top-level resourceSpans or resourceLogs key, in either the camelCase or snake_case spelling, and hands the bytes to husky. Using husky rather than a hand-written mapping means telemetry that arrives this way is translated by the same code path as telemetry sent to Honeycomb's OTLP endpoint, so field naming and dataset routing cannot drift apart. Not yet wired into the Telemetry API receiver. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A function instrumented with OpenTelemetry can now export to stdout instead
of to a collector sidecar, and the extension will pick the payload up and
deliver it. Records whose top level is an OTLP export request expand into one
event per span or log record; everything else is handled exactly as before.
The Telemetry API record is kept as raw bytes rather than being decoded to
interface{} first, so that nanosecond timestamps written as JSON numbers keep
their precision on the way to the translator.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers what the extension recognizes, that spans route by service.name, and the two constraints Lambda's log pipeline imposes: one line per payload, and small batches. Also warns that an SDK's built-in ConsoleSpanExporter is usually not OTLP, which is the most likely way to configure this wrong. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects a package doc that claimed translated spans are indistinguishable from spans sent to the OTLP endpoint; they carry lambda_extension.type, so they are not. Drops comments that restate the code they sit above, states the raw-bytes rationale once instead of twice, and replaces the invented 'Snek' field suffix. Flattens the record handling: the payload that might hold OTLP is derived once, so there is one translation attempt rather than three call sites. Error tests now assert husky's sentinel errors rather than merely that some error occurred, and there is coverage for snake_case payloads, a record naming two services, and a record carrying both signals at once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A message whose record is JSON null, or which has no record field at all, reached libhoney's Event.Add with a nil value and panicked it. net/http recovers the panic at the request boundary, so the extension survives, but every message after the offending one in that batch was dropped. Such a message now delivers what it does carry, its type and timestamp. Predates this branch: main panics on both shapes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous text said Java's OtlpJsonLoggingSpanExporter emits OTLP, which is true of the payload but misleading in practice: it writes through java.util.logging, whose default formatter prefixes a timestamp and INFO: and splits the record across two lines, leaving nothing the extension can parse. experimental-otlp/stdout is the value that writes OTLP JSON straight to stdout, one ResourceSpans per line. Documents all three plausible-looking values so the wrong one isn't chosen by name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An export request that translates to zero spans or log records no longer counts as handled: it fell through no path at all, being neither turned into events nor logged, so the record vanished. It now warns and is handled as an ordinary log line. Corrects two documented claims that were wrong. husky routes the signals asymmetrically for classic keys — spans honor the configured dataset, log records still prefer service.name — which neither the README nor the Translate doc comment said. And a truncated log line is not dropped; it arrives as one event carrying the broken text, which is what users will actually see. The README now also says to keep LIBHONEY_DATASET set. Reading the routing section and concluding it was unnecessary would have disabled the extension outright, taking platform events and plain log lines with it. Tests cover the sample rate reaching the event, empty export requests, and the classic-key routing asymmetry. io/ioutil gives way to io. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
OTLP/JSON on stdout is only reachable from OpenTelemetry Java today, via an experimental exporter. The Node, Python and Rust SDKs have no such exporter; what they do have is the otlp-stdout family from serverless-otlp-forwarder, which writes one JSON line wrapping a compressed, base64-encoded export request. Recognizing that envelope takes this from one language to four. The content type and encoding the envelope declares are passed to husky rather than assumed, so protobuf or JSON, gzip or zstd or uncompressed all work and a change to the exporter's defaults won't silently break parsing. The signal comes from the endpoint the payload was addressed to, since a compressed body can't be inspected for it. Costs nothing in binary size: the protobuf decoder was already linked for the JSON path. Also fits far more spans per line, which is the constraint most likely to bite. Detect and Translate give way to Parse and Translate, so both line formats resolve to one description of a payload rather than branching at the caller. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every fixture in this repo was written by hand, which means the tests have been
confirming our beliefs about the wire format rather than the format itself. One
of those beliefs was already wrong once.
testdata/capture deploys a throwaway function whose only job is to write one
line of each shape under test to stdout, records what the Telemetry API actually
delivers, and tears itself down. The recordings are replayed through the handler
under both of Lambda's log formats, and asserted to produce identical events —
the property that makes the log format a non-issue for users.
Two things the capture settled. A line that is already JSON arrives verbatim
under JSON log format, with no platform keys merged in; extra keys would have
made every OTLP payload fail to parse, since protojson rejects unknown fields.
And on a custom runtime a non-JSON line arrives as a bare string under both
formats, so the {timestamp, level, message} unwrap path is still covered only by
hand-written tests. Both are documented alongside the capture script.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The capture extension asked for its next event the moment one arrived, which hands Lambda back permission to freeze the environment before any of the invoke's telemetry has been delivered. That is why the first captures held only init-phase messages, and why the script had been invoking repeatedly to work around it. An invocation stays thawed until every registered extension asks for its next event, which is the window a real extension flushes in. The capture extension now holds that window until telemetry goes quiet, and one invoke is enough to record the function's own stdout. platform.report is still the exception: it is emitted only once the invocation has fully completed, so it cannot arrive within the invoke that produced it. The script keeps invoking until both are present. Captures are also deduplicated by content now, because changing the log format replaces the execution environment and each new one re-emits every payload. Goldens are byte-stable across re-captures as a result. The capture README described the wrong mechanism; corrected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Covers the lifecycle no unit test can reach: that the platform launches the extension from /opt/extensions, that registration succeeds, that INVOKE arrives and is handled, and that nothing panics or takes the sandbox down. That is the class of failure which made the extension unusable on Lambda Managed Instances, and the managed-instances registration path is now exercised directly — an inverted registration flag fails the test. Tagged rie and run via make test-rie, so the default suite still needs no Docker. Three emulator behaviors are documented in the package comment because each one cost time to find. It launches the runtime and extensions lazily on the first invocation, not at container start. It answers a telemetry subscription with a 2xx whose body reads Telemetry.NotSupported and then delivers nothing. And because that response is a 2xx, Subscribe never sees an error, so no test here can show what the extension does with a subscription that genuinely fails -- nor can the extension currently tell an accepted subscription from a silently ineffective one. A green run of this suite says nothing about OTLP translation; the captured payloads and replay tests cover that. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The emulator bundled in the Lambda base image stubs the Telemetry API: it accepts a subscription and delivers nothing, so the suite could only check that the extension starts, registers and survives. Translation was reachable only through unit tests posting payloads at the handler directly. The suite now builds an emulator that implements the Telemetry API, pinned to a commit, and asserts on what the extension actually delivers: the function writes each recognized shape to real stdout, the platform delivers it, and the batches are decoded and checked. OTLP/JSON traces and logs, the otlp-stdout envelope, a libhoney envelope and a plain log line are each covered, along with the rule that only translated telemetry routes away from the configured dataset. Disabling translation fails these tests, so they are load-bearing rather than decorative. Emulation is still emulation: the captured payloads in testdata remain the authority on what Lambda really sends. Point the suite at a different build with EMULATOR_REPO and EMULATOR_REF, including a local checkout. Drops the test for tolerating a Telemetry API that never delivers, which the patched emulator makes unreachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The extension is deployed rarely enough that a change deserves thorough testing, and until now CI only ran unit tests: nothing exercised the extension inside a real Lambda runtime, and nothing exercised the arm64 binary at all despite arm64 layers being published. The emulator suite now runs per architecture, and releases require it. It needs a machine executor rather than a docker one. The test publishes a container port and reaches it locally, and the container reaches a stand-in Honeycomb API in the job; with setup_remote_docker the daemon lives on another host and neither direction works. The suite also builds for the host's architecture now instead of always amd64, which is what makes the arm64 variant meaningful -- and means it runs natively rather than emulated on an arm64 workstation. The image's Go is used as-is: go.mod declares the version and the toolchain switches to match it, so nothing needs pinning here. The emulator build is cached per architecture and pinned ref, so only the first run pays for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lizthegrey
force-pushed
the
lizf.otlp-json-stdout
branch
from
July 28, 2026 20:51
d6caa61 to
d34d89e
Compare
Translated OTLP carries lambda_extension.type even though the OTLP endpoint would not add it. That is deliberate: annotating telemetry with the component that handled it is what Refinery does on the way through, and it lets a query tell a span that arrived via this extension from one sent to Honeycomb directly. Documented for users who will query the field, explained where the field is added, and pinned by a test so it is not mistaken for an oversight and removed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lizthegrey
marked this pull request as ready for review
August 5, 2026 22:48
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Which problem is this PR solving?
A function instrumented with OpenTelemetry can't use this extension today. Sending OTel telemetry from Lambda currently means either running a collector alongside the function, which the application connects to over gRPC, or routing telemetry through CloudWatch.
Meanwhile this extension already has a cheaper path: read what the function writes to stdout, translate it, ship it. It just only understood Honeycomb's own JSON.
This teaches it OTLP/JSON, so instrumenting with OTel and pointing the exporter at stdout is enough — no collector process and no socket for the application to connect to.
Short description of the changes
Sixteen commits, each independently reviewable, in four groups.
The feature.
otlpjsonrecognizes an OTLP export request and hands it to husky; the Telemetry API receiver expands one such record into an event per span or log record, leaving everything else handled exactly as before. A later commit adds theotlp-stdoutenvelope — see below for why.Two fixes worth separating out. A record of JSON
null, or a message with no record at all, reached libhoney'sAddwith a nil value and panicked it, costing the rest of that batch;mainpanics on both shapes, so this predates the branch. And an export request that translated to zero spans was handled by no path at all — neither turned into events nor logged — so it vanished silently.Build and docs.
-s -wstrips the symbol table and DWARF from the layer zip Lambda downloads at cold start (see the size table below); the README documents which exporters actually work, which took a correction after the first version named one that doesn't.Testing, which is most of the commit count: payloads captured from a real Lambda and replayed through the handler, the extension running inside a real Lambda runtime, and CI running that on both published architectures.
Traces and logs. Not metrics.
Two line formats, because OTLP/JSON alone only reaches Java
This started as OTLP/JSON only, and that turned out to cover one language. Checking what each SDK can actually emit:
OTEL_TRACES_EXPORTER=experimental-otlp/stdout(1.43.0+). Experimental, as named.ConsoleSpanExportercallsconsole.dir(…, {depth: 3})— Node's inspect format, not JSON, multi-line, elided below depth 3, and documented as subject to change at any time.to_json(), multi-line by design since #505.Writing adapters for those console formats would mean parsing output their own maintainers call unstable and diagnostic-only, so this instead accepts the
otlp-stdoutexporters that Node, Python and Rust do have. They emit one JSON line wrapping a compressed, base64-encoded export request. That takes coverage from one language to four.The envelope's declared
content-typeandcontent-encodingare passed to husky rather than assumed, so protobuf or JSON, gzip or zstd or uncompressed all work, and a change to the exporters' defaults won't silently break parsing. The signal comes from theendpointthe payload was addressed to, since a compressed body can't be inspected for it.Two things worth noting: it costs nothing in binary size, because the protobuf decoder was already linked for the JSON path; and since the payload is compressed, it fits far more spans into a line before hitting Lambda's truncation limit — the constraint most likely to bite in practice.
These are community packages, not part of OpenTelemetry proper. If we'd rather not build on a third-party envelope, the alternative is Java-only until upstream ships stdout exporters, and that's a reasonable call to make in review.
Why husky rather than a hand-written mapping
husky/otlpis the same library Honeycomb's OTLP ingest uses. Reusing it means field naming, resource-attribute flattening, sample rate, timestamps and dataset routing are by construction identical to what the same spans would produce through the OTLP endpoint — there's no second mapping to drift out of sync. The alternative was ~350 lines re-implementingtrace.parent_id,duration_ms,span.kind,meta.annotation_typeand friends, and owning that indefinitely.Size: husky costs 2.6 MiB, stripping refunds 3.3 MiB
husky pulls in otel-proto and the protobuf runtime, so this was the first thing measured. x86_64,
zip -9:-s -wStating this plainly rather than letting the two commits net out to an implied win: husky costs +2.6 MiB zipped and +7.4 MiB on disk, and every user pays it at cold start whether or not they emit OTLP. The shipped layer still shrinks 7.13 → 6.41 MiB (−10%), but that's the stripping paying for it, and the strip commit stands on its own — it would be worth taking even if this feature were rejected.
Worth knowing for anyone re-measuring: of husky's cost, the bulk is the OTLP generated structs plus the protobuf runtime, which resists dead-code elimination because it registers types reflectively. The scary-looking transitive deps (gonum, grpc-gateway, collector-contrib/sampling) are eliminated and cost ~0.5 MiB, and adding the logs entrypoint on top of traces costs nothing measurable. There is no smaller subset of
husky/otlpto import — it's a single package.Translated telemetry keeps the extension's marker
Translated OTLP carries
lambda_extension.type, which the OTLP endpoint would not add. That is deliberate rather than an oversight: annotating telemetry with the component that handled it is what Refinery does on the way through, and it is how a query tells a span that arrived via this layer from one sent to Honeycomb directly. Documented in the README and pinned by a test.Dataset routing changes for OTLP events only
Spans go to the dataset named by their
service.name, as they would via the OTLP endpoint.LIBHONEY_DATASETremains the destination for classic keys and for every non-OTLP record. This is a deliberate behavior difference from everything else the extension emits; the alternative — collapsing all services into one configured dataset — seemed worse than matching the endpoint.LogMessage.Recordis nowjson.RawMessageThis is the largest mechanical part of the diff and the part most worth a look. The record is kept as raw bytes instead of being decoded to
interface{}first, so the translator sees exactly what the function wrote. Decoding first would round-trip nanosecond timestamps through a float64 and quietly lose the low bits —TestOTLPNumericNanosecondsKeepFullPrecisioncovers that case. The cost is that existing test fixtures now build records as wire JSON rather than Go maps. No pre-existing test changed its expectations, only its fixture syntax.Testing
Three layers, weakest evidence first.
Unit tests. Detection across 12 cases including the near-misses (
resourceSpansnested rather than top level, libhoney envelopes, non-JSON lines); translation of traces and logs; classic-versus-E&S dataset routing, including husky's asymmetry between the two signals; husky's sentinel errors rather than merely "an error occurred". Through the HTTP handler: both of Lambda's log formats, multi-span fan-out, platform records, malformed OTLP, export requests that translate to nothing, and record shapes that used to panic libhoney.Replay of telemetry captured from a real Lambda function. Every fixture used to be one I wrote, which meant the tests confirmed my beliefs about the wire format rather than the format itself — and one of those beliefs was already wrong.
telemetryapi/testdata/capture/deploys a throwaway function that writes each shape to stdout, records what the Telemetry API actually delivers, and tears itself down; the recordings are replayed through the handler. Both log formats are captured, and asserted to produce identical events, which is the property that makes log format a non-issue for users.Two things that capture settled, neither of which was knowable by reading: under JSON log format an already-JSON line arrives verbatim, with no platform keys merged in (extra keys would make every OTLP payload fail to parse, since protojson rejects unknown fields); and on a custom runtime a non-JSON line arrives as a bare string under both formats, so the
{timestamp, level, message}unwrap path is still covered only by hand-written tests.The extension running inside a real Lambda runtime.
make test-riebuilds the extension into the Lambda base image at/opt/extensions, and the platform starts it, registers it, and delivers telemetry over the real Extensions and Telemetry APIs. The events it sends are decoded and asserted on: OTLP/JSON traces and logs, theotlp-stdoutenvelope, a libhoney envelope and a plain log line, plus the rule that only translated telemetry routes away from the configured dataset. It also covers the registration lifecycle, including the Lambda Managed Instances path whose mishandling made an earlier release unusable.This runs in CI on both x86_64 and arm64 — arm64 layers are published and nothing exercised them before — and releases now require it. The emulator bundled in the base image stubs the Telemetry API, so the suite builds one that implements it, pinned to a commit;
EMULATOR_REPO/EMULATOR_REFpoint it elsewhere, including at a local checkout.Claims were checked by mutation rather than assertion — breaking the implementation and confirming a test notices. The nanosecond-precision guard, the sample rate reaching the event, the null-record guard, the function-type gate, the per-event dataset override, OTLP detection and envelope support all fail their tests when reverted.
What is still not tested. The extension itself has never run on a real Lambda: the capture above deployed a purpose-built recorder, not this code, and an emulator is not the platform. The Java exporter guidance is reasoned from upstream sources rather than observed in AWS. Both remain reasons to try this on real functions before recommending it to anyone.
Two rounds of review already applied
An adversarial review pass found things worth recording, since they shaped the diff:
OtlpJsonLoggingSpanExporter. That exporter emits lines with noresourceSpanswrapper (opentelemetry-java#6749) and writes throughjava.util.logging, so following the original docs would have silently produced nothing.experimental-otlp/stdoutis the value that works.service.name). Documented and pinned by a test.LIBHONEY_DATASETwasn't their destination would have led some to unset it, which disables the extension completely. The README now says to keep it.nullpanics libhoney and costs the rest of the batch. This one predates the branch —mainpanics identically, and on the absent-recordcase too. Fixed here since the surrounding code was already being touched.Open questions for review
otlp-stdoutpackages that give Node, Python and Rust a working path are from serverless-otlp-forwarder, not OpenTelemetry proper. Accepting their envelope is what takes this beyond Java. If we would rather only support formats that come from upstream, this is Java-only until the OTLP File exporters stabilize — a legitimate call to make.recordfield: no spans, no warning. The README says to keep batches small, which is advice rather than a safeguard. Should the extension recognize a truncated OTLP payload and say so?test/riebuilds an emulator with Telemetry API support from a personal fork, pinned to a commit, because the one in the Lambda base image stubs that API. If the upstream PR lands, that constant becomes an upstream ref. Reviewers may reasonably want this suite gated differently until then.🤖 Generated with Claude Code