Conversation
The /v1/traces and /v1/logs endpoints accepted uncompressed JSON or protobuf but were not a compliant OTLP/HTTP server. Four defects, each confirmed in code and reproduced end to end: - No gzip support. A stock OTel Collector gzip-compresses by default, so its requests failed in ParseFromString and returned an opaque 500. Verified against the real exporter: the export failed after retries and every span was silently lost, while uncompressed exports succeeded. - Malformed payloads returned 500 instead of 400 + google.rpc.Status. A 500 is retryable, so clients retried bodies that could never succeed. - Responses never mirrored the request Content-Type; protobuf clients got JSON back. - Spans and logs dropped at the per-session caps reported full success instead of partial_success, hiding data loss from the caller. Adds otlp_http.py as the protocol layer (media-type resolution, gzip decode, Status error bodies, app-scoped exception handlers) so the route handlers stay thin and the logic is testable without HTTP. Compression is bounded on both the wire body and the decompressed output, and concatenated gzip members are counted: decoding cost scales with member count rather than output, so a body of 20-byte empty members would otherwise burn seconds of CPU and still answer 200. Decompression runs off the event loop, which this process shares with the dashboard API, the UI and the gRPC receiver. process_traces/process_logs now return an ExportResult so drops are reported; the gRPC receiver shares those functions and had the same silent-success bug. Tests: new tests/test_otlp_http_protocol.py (deliberately outside tests/integration/, which CI excludes) plus unit coverage for the counts and gRPC parity.
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.
Closes #171.
Problem
/v1/tracesand/v1/logsaccepted uncompressed JSON or protobuf, but were not a compliant OTLP/HTTP server. Four conformance gaps, each reproduced end to end against the current code:No gzip support. A stock OTel Collector gzip-compresses by default. Those requests arrived with
Content-Type: application/x-protobufplusContent-Encoding: gzip, took the protobuf branch, and failed inParseFromStringwith an opaque 500. Measured against a live receiver with the realopentelemetry-exporter-otlp-proto-http: the export failed after retries and every span was silently lost, while the identical export uncompressed succeeded. This is the default configuration of the most common client — not an edge case.Malformed payloads returned 500, not 400 +
google.rpc.Status. There was notry/excepton these routes and noexception_handleranywhere in the repo, sojson.JSONDecodeErrorand protobufDecodeErrorbubbled into Starlette'sServerErrorMiddleware. A 500 is in the retryable class, so clients retried bodies that could never succeed.Responses never mirrored the request
Content-Type. Both handlers returned the hard-coded literal{"partialSuccess":{}}withmedia_type="application/json"— including for protobuf requests, which the spec requires be answered with a protobufExportTraceServiceResponse.Dropped spans and logs reported full success. The per-session caps were enforced by a bare
continue(the log path did not even log a warning), and the response was an unconditional success, so the caller could not learn that data had been discarded.Why this matters for an evaluation tool. agentevals derives its results from ingested traces. Gaps 1 and 4 mean traces disappear while ingest reports success, so evaluations run on incomplete data with no signal — and the discarded traces are disproportionately the long, busy, or failing runs, which is exactly when the trace matters most. Gap 2 turns a permanently bad payload into an unbounded retry loop against a local server.
Solution
Added
api/otlp_http.pyas an explicit OTLP/HTTP protocol layer — content negotiation, compression,google.rpc.Statuserror bodies, and exception handlers — leaving the route handlers genuinely thin. This follows the layering the module docstring already claimed and keeps the logic unit-testable without HTTP.google.rpc.Statusis used directly rather than hand-encoded. The deciding argument is that a hand-rolled encoder verified by a hand-rolled decoder proves nothing about conformance;Status.FromString(resp.content)is real evidence.require_trace_manageris shared withstreaming_routesanddebug_routes, which must keep their{"detail": ...}error shape.process_traces/process_logsnow return anExportResult, so rejection counts are reportable. The gRPC receiver shares those functions and had the identical silent-success bug, so it is fixed in the same change — otherwise the two transports would disagree.Changes
api/otlp_http.pyStatusresponses, app-scoped handlersapi/otlp_routes.pyread → process → respondapi/otlp_processing.pyExportResult, counts at each drop site,build_traces_response/build_logs_responseapi/otlp_app.pyapi/otlp_grpc.pypartial_successtoopyproject.toml,uv.lockgoogleapis-common-protosadded as a direct dependency (see Notes)tests/test_otlp_http_protocol.pytests/test_otlp_receiver.pyExportResultcounts + gRPCpartial_successdocs/otel-compatibility.md,docs/streaming.md,README.mdNo new trace, span, log or metric is introduced. The change is to what the receiver reports about itself.
Evaluation
No LLM-evaluation metrics apply here, and I have not invented any. This change alters the ingest transport and its error signalling, not model or agent behavior, so tool-call accuracy, trajectory success and token cost are unaffected by construction. The evaluation-relevant risk is data integrity, so that is what I measured.
opentelemetry-exporter-otlp-proto-http→ real uvicorn receiver500(non-retryable data, retryable code)200500, plain text or{"detail"}400+ decodablegoogle.rpc.StatuspartialSuccess.rejectedSpans/rejectedLogRecordsDataset / test cases: no dataset — the units of evidence are the 53 protocol tests plus a three-mode end-to-end run (gzip / library default / no compression) against a live server.
Reproduce the headline result:
Observability
The receiver's self-reporting is the observability surface here, so this is the core of the change rather than a side effect:
partial_successon both transports.rejected_spans/rejected_log_recordsplus an Englisherror_message, populated only when records are actually dropped. Per spec,partial_successis left unset on full success, so a clean export is byte-identical to before in meaning.google.rpc.Statusbodies on every 4xx/5xx, encoding-mirrored. Previously these were plain text,{"detail": ...}, or nothing at all.logger.warningon the log cap, which was previously silent while the span path already warned.partialSuccessand/v1/tracesappear only in the receiver code and docs.How to view: point a Collector at
:4318and inspect the export response, or run the receiver in non-live mode and observe the503Statusbody. Example of a capped export response:{"partialSuccess":{"rejectedSpans":"3","errorMessage":"3 span(s) rejected: session has reached maximum span limit (10000)"}}(
"3"is a string because proto3 JSON mapsint64to string; the proto field isint64.)Testing
uv run pytest -m "not integration and not e2e" -q→ 829 passed, 6 skipped (CI's unit job)uv run pytest tests/integration/ -m "integration and not e2e" -q→ 29 passeduv run ruff check --no-fix .anduv run ruff format --check .→ cleanuv lock --check→ cleanCoverage: normal path (JSON and protobuf, both signals) · boundaries (empty body, exactly-at-cap, at member cap, missing
Content-Type) · failure paths (malformed JSON, malformed protobuf, truncated / corrupt / over-cap gzip, structural JSON violations, deep nesting, wrongContent-Encoding, unknown media type, unhandled exception → 500, live mode off → 503) · compatibility (full-success body shape, UI unaffected, main app error shape unchanged) · concurrency (decompression runs off the event loop; all candidates rejected in milliseconds).No real LLM API is required. Every test uses
httpx.ASGITransportagainst the app; the end-to-end script uses a local receiver and a local exporter with no provider calls.make test-e2e(which does need keys) was not run and is unaffected.The new test file is deliberately outside
tests/integration/: that directory is markedpytest.mark.integrationper file and CI's test job runs-m "not integration and not e2e", so anything placed there would never execute in CI. This change is a conformance fix; its tests must gate it.Notes for Reviewer
Four decisions worth your call:
googleapis-common-protos. Required forgoogle.rpc.Status. It was already resolved inuv.lock(transitively viagoogle-adk→google-api-core), so this promotes an existing pin rather than adding a package — butpyproject.tomlwarns that pip ignores lockfiles, so relying on the transitive path would be wrong. If you'd rather not depend on it, the fallback is hand-encoding field 2 ofStatus.partial_successcounts session-cap drops and records with notrace_id, but NOT filtered non-gen_ai.*log records. Those are dropped by design — an app instrumented with many libraries emits far more non-GenAI logs than GenAI ones, so counting them would attach a permanent warning to every export with no possible user action. The filter is now documented explicitly rather than left implicit. Orphan-buffered logs are likewise not counted: they are deferred, not rejected.HTTP 4xxandHTTP 5xxresponses MUST be a Protobuf-encodedStatusmessage") carries no JSON exemption, and mirroring is the reading that does not also violate the Content-Type rule. Called out in the docs.400, not413. The other caps are about size; this one says the body is structurally pathological. Both are permanent, which is what matters for retry behaviour — but it's a judgement call.Deliberate behaviour changes:
{"partialSuccess":{}}to{}. These are proto3-JSON equivalent (an empty message is indistinguishable from unset), and the spec requires the field be left unset on success.gzip.GzipFileand the gzip FAQ. A hand-rolledzlib.decompressobjloop rejects it by default, so this is explicit — with a test, since it is the kind of subtle delta a refactor would silently reintroduce.process_traces/process_logsnow returnExportResultinstead ofNone. Any out-of-tree caller would see a changed (additive) return type; all in-tree callers are updated.Where to look hardest: the gzip decompression bounds. Decoding cost scales with the number of concatenated members, not with their output, so a body of 20-byte empty members costs CPU in proportion to its size while producing nothing — it would otherwise burn seconds and still answer
200. The member cap is what actually bounds the work; measured, an uncapped 1.25 MB body of empty members takes 5.90 s versus 19.6 ms rejected. TheMAX + 1probe in the read is load-bearing: it is what keeps "expanded past the cap" at413while corruption stays400.Known limitations, not addressed here:
MessageToDict(~16x). This is inherent to materialising OTLP into dicts and needs a design decision, not a cap._replay_orphan_logs/_absorb_orphan_for_tracebypasscan_accept_log()when extending a session, so a session can exceed its cap after replay — meaning the reportedrejectedcount can understate true drops in that path. Pre-existing and narrow.session_name(otlp_processing.py,%srather than%r) is pre-existing onmainand left alone to keep this diff focused. It is a one-line fix and worth its own PR.Self-check
main(0 spans) vs this branch (1 span).process_*gains a return value. The UI and the main API's error shape are verified unaffected. Existing JSON clients are unaffected for valid payloads.partial_success.