feat(traces): span batch transport - #952
turnipdabeets wants to merge 3 commits into
Conversation
Prompt To Fix All With AI### Issue 1
posthog/tracing/_transport.py:101-111
**Redirects can discard traces**
If a custom or self-hosted ingestion endpoint returns a 301, 302, or 303 redirect, `requests` follows it by default and may rewrite the POST as a GET. The final 2xx response is then reported as success even though the OTLP body was not ingested. Disable implicit redirects, or explicitly follow only bounded, same-origin 307/308 redirects while preserving the POST.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "feat(traces): span batch transport" | Re-trigger Greptile |
posthog-python Compliance ReportDate: 2026-09-17 02:13:52 UTC ✅ All Tests Passed!111/111 tests passed Capture_V1 Tests✅ 94/94 tests passed View Details
Feature_Flags Tests✅ 17/17 tests passed View Details
|
1f1d603 to
13c8282
Compare
13c8282 to
d33a226
Compare
8f5b3c1 to
fbbbe38
Compare
|
Reviews (2): Last reviewed commit: "feat(traces): span batch transport" | Re-trigger Greptile |
dustinbyrne
left a comment
There was a problem hiding this comment.
One transport issue to address before the public tracing wiring:
At posthog/tracing/_transport.py:101–110, this POST downloads the entire response body even though classification uses only status and headers. Requests' timeout bounds read inactivity, not total response duration. A proxy returning 503 headers followed by an unfinished body with periodic chunks can keep this call open indefinitely instead of returning retry-later. The intended exporter holds its single-flight slot across the call.
Please classify a streamed response and reliably close it without consuming the unused body. That fixes this body-related hang; it does not by itself prove a whole-request deadline, particularly across redirects.
Regression sketch (not executed): use a local server returning 503 with a chunked body that sends every 10 ms without finishing; use a 0.5-second client timeout and assert the transport returns retry-later within two seconds. A completed 200 response is the negative control. Release the server loop during cleanup.
This is a private foundation issue, not a currently shipped Client tracing regression.
AI-assisted review with independent validation of source, pinned Requests/urllib3 implementations and existing CI; no new tests were run.
|
Good catch, fixed in 12bc88f. We now read only the status and headers, then close the response without reading the body. Added a test with a local server that returns 503 and keeps sending chunks forever. It hung before and now returns retry-later in under two seconds. |
dustinbyrne
left a comment
There was a problem hiding this comment.
Thanks, this addresses my transport finding. I checked the streamed response, close in finally, and the new dripping-body regression test in 12bc88f. Current CI is green. Approving.
AI-assisted source and existing-CI review; I did not run new tests locally.
jzhu13
left a comment
There was a problem hiding this comment.
Reviewed against traces/03-span-handles. Tests pass at the head, and the outcome classification is sound: fatal drops, too-large halves to one then drops, retry-later is capped by #954's budget, so nothing can loop forever on a bad batch. The server-side route, bearer auth, and gzip handling in capture-logs match. One item I would fix before merge.
Blocking
posthog/tracing/_transport.py:101stream=Trueplusresponse.close()without draining tears down the pooled connection after every batch. Reproduced: five traces POSTs to a keep-alive local server opened five TCP connections and the server logged aConnectionResetErroron each; five plain_get_session().post(...).contentcalls used one. Every flush pays a fresh TCP+TLS handshake to the ingestion host, and the 4xxlog.errorcan never include the server's{"error": ...}body, so a mistyped key surfaces as bareHTTP 401every interval. The drip protection is worth keeping. Suggest draining whenContent-Lengthis present and small (capture-logs always sets it on itsJson(...)responses) and closing unread only when it is absent or large.
Non-blocking
posthog/tracing/_transport.py:33SendOutcome.kindis a free-formstr. #954's_apply_outcome_lockedends in a fallthrough that deletes the batch, so a typo in any future call site silently drops spans instead of failing typecheck.Literal["ok", "retry-later", "too-large", "fatal"]closes that.posthog/tracing/_transport.py:49parse_retry_afteris the third Retry-After parser in the SDK, afterrequest._process_responseandcapture_v1._parse_retry_after, and the most correct of the three. It belongs inposthog/request.pywithcapture_v1pointed at it.- Nits: the
try/exceptaroundparse_retry_after(response.headers.get(...))is dead code propped up by a mock-only test, since the parser returnsNoneon every malformed input;test_transport.py:155asserts a 2099 HTTP-date against the real clock;gzip.compressat level 9 measured 2x the CPU of level 6 for about 1% more bytes.
Reviewed with Claude Code (Claude Fable 5.1). Behaviors above were reproduced against this branch head.
12bc88f to
be59068
Compare
PR overviewAll previously flagged issues have been addressed. No open security concerns remain on this pull request. Security reviewNo open security issues remain on this pull request. Fixed/addressed: 1 · PR risk: 0/10 |
|
Thanks. Fixed in be59068:
|
Adds the HTTP transport: one gzipped OTLP JSON POST per batch to
{host}/i/v1/traces with bearer auth, classified as ok (2xx), too large (413, or
a body over the 10 MiB hosted ingestion limit, refused without a request),
retriable (408, 429, 5xx, transport errors, carrying any Retry-After as
delta-seconds or HTTP-date) or fatal (other 4xx). Not reachable from the client.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TkZAsCciW4PV8ZdcCHmAbA
A requests timeout bounds read inactivity, not the whole response, so a proxy answering 503 with a body that keeps dripping held the exporter's single flight open indefinitely. Stream the response, classify it from status and headers, and close it unread.
…vives Closing an unread streamed response tears the connection down, so every batch paid a new TCP and TLS handshake. A body whose Content-Length is at most 64 KiB is read before close, and a fatal status now logs it, so a bad key shows the server's error rather than a bare status. The outcome kind is a Literal, gzip runs at level 6, and the dead Retry-After guard and its mock-only test are gone.
be59068 to
267f783
Compare
|
Follow-up: nothing further changed here since be59068; the stack below it was rebased. Ready for another look. |
💡 Motivation and Context
Adds the HTTP transport: one gzipped OTLP JSON POST per batch to
{host}/i/v1/traceswith bearer auth. Responses are classified as:Retry-Afteras delta-seconds or HTTP-dateNot reachable from the client yet.
Stack (PR 4 of 9, based on
traces/03-span-handles):traces/01-ids-traceparenttraces/02-otlp-encodingtraces/03-span-handlestraces/04-transport← this PRtraces/05-pipelinetraces/06-exporttraces/07-span-limitstraces/08-before-span-sendtraces/09-client-wiring💚 How did you test it?
Unit tests in
posthog/test/tracing/test_transport.pycover the request shape, every response class andRetry-Afterparsing.📝 Checklist
If releasing new changes
sampo addto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Implemented with Claude Code (Claude Opus 5) against the traces spec, one commit per slice so each PR reviews on its own. Rebased onto main and opened as a stacked draft in a later Claude Code session (Claude Fable 5.1).
🤖 Generated with Claude Code
https://claude.ai/code/session_012o7CtHLfcypjmXL7g9ZGRC