Skip to content

Commit fbbbe38

Browse files
turnipdabeetsclaude
andcommitted
feat(traces): span batch transport
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
1 parent cf74b0b commit fbbbe38

2 files changed

Lines changed: 329 additions & 0 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
import gzip
2+
import json
3+
from datetime import datetime, timezone
4+
from types import SimpleNamespace
5+
from unittest import mock
6+
7+
import pytest
8+
import requests
9+
10+
from posthog.tracing._transport import (
11+
OTLP_MAX_BODY_BYTES,
12+
SendOutcome,
13+
parse_retry_after,
14+
send_traces_batch,
15+
)
16+
from posthog.version import VERSION
17+
18+
PAYLOAD = {"resourceSpans": [{"scopeSpans": [{"spans": [{"name": "x"}]}]}]}
19+
20+
21+
def fake_client(**overrides):
22+
base = dict(
23+
disabled=False,
24+
send=True,
25+
host="https://us.example.com/",
26+
api_key="phc_test_key",
27+
timeout=7,
28+
)
29+
base.update(overrides)
30+
return SimpleNamespace(**base)
31+
32+
33+
def mock_session(status_code=200, headers=None):
34+
session = mock.Mock()
35+
session.post.return_value = mock.Mock(
36+
status_code=status_code, headers=headers or {}
37+
)
38+
return session
39+
40+
41+
def send(client=None, payload=PAYLOAD, session=None):
42+
session = session or mock_session()
43+
with mock.patch("posthog.tracing._transport._get_session", return_value=session):
44+
outcome = send_traces_batch(client or fake_client(), payload)
45+
return outcome, session
46+
47+
48+
class TestRequestShape:
49+
def test_posts_to_the_traces_endpoint_with_bearer_auth(self):
50+
outcome, session = send()
51+
assert outcome == SendOutcome("ok")
52+
args, kwargs = session.post.call_args
53+
assert args[0] == "https://us.example.com/i/v1/traces"
54+
assert kwargs["headers"]["Authorization"] == "Bearer phc_test_key"
55+
assert kwargs["headers"]["User-Agent"] == "posthog-python/" + VERSION
56+
assert kwargs["timeout"] == 7
57+
58+
def test_does_not_put_the_project_key_in_the_query_string(self):
59+
_, session = send()
60+
assert "token=" not in session.post.call_args[0][0]
61+
62+
def test_gzips_the_json_body_and_says_so(self):
63+
_, session = send()
64+
kwargs = session.post.call_args[1]
65+
assert kwargs["headers"]["Content-Encoding"] == "gzip"
66+
assert kwargs["headers"]["Content-Type"] == "application/json"
67+
assert json.loads(gzip.decompress(kwargs["data"])) == PAYLOAD
68+
69+
def test_falls_back_to_the_default_timeout(self):
70+
_, session = send(fake_client(timeout=None))
71+
assert session.post.call_args[1]["timeout"] == 15
72+
73+
74+
class TestGates:
75+
def test_disabled_client_is_fatal_without_a_request(self):
76+
outcome, session = send(fake_client(disabled=True))
77+
assert outcome.kind == "fatal"
78+
assert not session.post.called
79+
80+
def test_send_false_is_ok_without_a_request(self):
81+
outcome, session = send(fake_client(send=False))
82+
assert outcome.kind == "ok"
83+
assert not session.post.called
84+
85+
def test_an_oversized_body_is_too_large_without_a_request(self):
86+
payload = {"resourceSpans": [{"blob": "x" * (OTLP_MAX_BODY_BYTES + 1)}]}
87+
outcome, session = send(payload=payload)
88+
assert outcome.kind == "too-large"
89+
assert outcome.measured_locally
90+
assert not session.post.called
91+
92+
def test_a_413_is_not_marked_as_measured_locally(self):
93+
outcome, _ = send(session=mock_session(413))
94+
assert outcome.kind == "too-large"
95+
assert not outcome.measured_locally
96+
97+
def test_the_limit_is_what_hosted_ingestion_accepts(self):
98+
assert OTLP_MAX_BODY_BYTES == 10 * 1024 * 1024
99+
100+
def test_a_body_exactly_at_the_limit_is_sent(self):
101+
# {"s":""} is 8 bytes of JSON around the string.
102+
outcome, session = send(payload={"s": "x" * (OTLP_MAX_BODY_BYTES - 8)})
103+
assert outcome.kind == "ok"
104+
assert session.post.called
105+
106+
def test_a_body_one_byte_over_the_limit_is_not_sent(self):
107+
outcome, session = send(payload={"s": "x" * (OTLP_MAX_BODY_BYTES - 7)})
108+
assert outcome.kind == "too-large"
109+
assert not session.post.called
110+
111+
def test_measures_the_uncompressed_body(self):
112+
# Compresses to a few kilobytes.
113+
outcome, session = send(payload={"s": "\u2603" * OTLP_MAX_BODY_BYTES})
114+
assert outcome.kind == "too-large"
115+
assert not session.post.called
116+
117+
118+
class TestOutcomes:
119+
@pytest.mark.parametrize(
120+
"status,kind",
121+
[
122+
(200, "ok"),
123+
(204, "ok"),
124+
(413, "too-large"),
125+
(408, "retry-later"),
126+
(429, "retry-later"),
127+
(500, "retry-later"),
128+
(503, "retry-later"),
129+
(400, "fatal"),
130+
(401, "fatal"),
131+
(404, "fatal"),
132+
],
133+
)
134+
def test_maps_status_codes(self, status, kind):
135+
outcome, _ = send(session=mock_session(status))
136+
assert outcome.kind == kind
137+
138+
def test_a_transport_error_is_retriable(self):
139+
session = mock.Mock()
140+
session.post.side_effect = requests.exceptions.ConnectionError("down")
141+
outcome, _ = send(session=session)
142+
assert outcome == SendOutcome("retry-later")
143+
144+
def test_reads_retry_after_delta_seconds(self):
145+
outcome, _ = send(session=mock_session(429, {"Retry-After": "120"}))
146+
assert outcome == SendOutcome("retry-later", 120.0)
147+
148+
def test_reads_retry_after_http_date(self):
149+
outcome, _ = send(
150+
session=mock_session(503, {"Retry-After": "Wed, 21 Oct 2099 07:28:00 GMT"})
151+
)
152+
assert outcome.kind == "retry-later"
153+
assert outcome.retry_after is not None and outcome.retry_after > 0
154+
155+
156+
NOW = datetime(2026, 9, 10, 12, 0, 0, tzinfo=timezone.utc)
157+
158+
159+
class TestParseRetryAfter:
160+
@pytest.mark.parametrize(
161+
"value,expected",
162+
[
163+
("120", 120.0),
164+
(" 30 ", 30.0),
165+
("60, 120", 60.0),
166+
("Thu, 10 Sep 2026 12:00:30 GMT", 30.0),
167+
],
168+
)
169+
def test_reads_both_wire_forms(self, value, expected):
170+
assert parse_retry_after(value, NOW) == expected
171+
172+
@pytest.mark.parametrize(
173+
"value",
174+
[
175+
None,
176+
"",
177+
"0",
178+
"-5",
179+
"+5",
180+
"5.5",
181+
"1e3",
182+
"10 minutes",
183+
"Wed, 21 Oct 2015 07:28:00 GMT",
184+
"Thu, 10 Sep 2026 12:00:00 GMT",
185+
42,
186+
],
187+
)
188+
def test_treats_anything_else_as_absent(self, value):
189+
assert parse_retry_after(value, NOW) is None
190+
191+
def test_ignores_an_unparseable_retry_after(self):
192+
outcome, _ = send(session=mock_session(429, {"Retry-After": "10 minutes"}))
193+
assert outcome == SendOutcome("retry-later", None)
194+
195+
def test_survives_a_throwing_headers_object(self):
196+
response = mock.Mock(status_code=503)
197+
response.headers.get.side_effect = RuntimeError("no headers")
198+
session = mock.Mock()
199+
session.post.return_value = response
200+
outcome, _ = send(session=session)
201+
assert outcome == SendOutcome("retry-later", None)

posthog/tracing/_transport.py

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
"""HTTP transport for span batches: one POST to ``/i/v1/traces`` per batch."""
2+
3+
import gzip
4+
import json
5+
import logging
6+
import math
7+
import re
8+
from dataclasses import dataclass
9+
from datetime import datetime, timezone
10+
from email.utils import parsedate_to_datetime
11+
from typing import Any, Optional
12+
13+
import requests
14+
15+
from ..request import USER_AGENT, _get_session
16+
from ..utils import remove_trailing_slash
17+
18+
log = logging.getLogger("posthog")
19+
20+
TRACES_PATH = "/i/v1/traces"
21+
22+
# PostHog's hosted ingestion accepts 10 MiB (measured decompressed); a larger
23+
# body can only come back 413, so it is refused without a request. A proxy or
24+
# self-hosted deployment enforcing less is covered by the 413 path.
25+
OTLP_MAX_BODY_BYTES = 10 * 1024 * 1024
26+
27+
_RETRIABLE_STATUSES = frozenset({408, 429})
28+
29+
_DELTA_SECONDS_RE = re.compile(r"^\d+$")
30+
_NUMERIC_RE = re.compile(r"^[+-]?[\d.]+$")
31+
32+
33+
@dataclass(frozen=True)
34+
class SendOutcome:
35+
"""How one export attempt went: ``ok``, ``retry-later``, ``too-large`` or ``fatal``."""
36+
37+
kind: str
38+
retry_after: Optional[float] = None
39+
# Too large by the SDK's own measure, so no request was spent (too-large only).
40+
measured_locally: bool = False
41+
42+
43+
OK = SendOutcome("ok")
44+
TOO_LARGE = SendOutcome("too-large")
45+
TOO_LARGE_LOCALLY = SendOutcome("too-large", measured_locally=True)
46+
FATAL = SendOutcome("fatal")
47+
48+
49+
def parse_retry_after(value: Any, now: Optional[datetime] = None) -> Optional[float]:
50+
"""``Retry-After`` as seconds from now; ``None`` when absent, malformed or not in the future.
51+
52+
Accepts delta-seconds or an HTTP-date. A repeated header arrives joined as
53+
``"60, 120"``; the first value is the outermost hop's.
54+
"""
55+
if not isinstance(value, str) or not value.strip():
56+
return None
57+
raw = value.strip()
58+
if re.match(r"^\d+\s*,", raw):
59+
raw = raw.split(",", 1)[0].strip()
60+
if _DELTA_SECONDS_RE.match(raw):
61+
seconds = float(raw)
62+
elif _NUMERIC_RE.match(raw):
63+
return None
64+
else:
65+
try:
66+
when = parsedate_to_datetime(raw)
67+
except (TypeError, ValueError, IndexError):
68+
return None
69+
if when.tzinfo is None:
70+
when = when.replace(tzinfo=timezone.utc)
71+
seconds = (when - (now or datetime.now(timezone.utc))).total_seconds()
72+
if not math.isfinite(seconds) or seconds <= 0:
73+
return None
74+
return seconds
75+
76+
77+
def send_traces_batch(client: Any, payload: dict) -> SendOutcome:
78+
"""POST one OTLP batch with bearer auth and gzip, classifying the response.
79+
80+
2xx is ok; 413 is too large; 408, 429, 5xx and transport errors are
81+
retriable; any other status is fatal. A 2xx is not proof of ingestion: an
82+
unknown but well-formed key is accepted and the spans dropped downstream.
83+
"""
84+
if getattr(client, "disabled", False):
85+
return FATAL
86+
if not getattr(client, "send", True):
87+
return OK
88+
89+
serialized = json.dumps(payload, separators=(",", ":")).encode("utf-8")
90+
if len(serialized) > OTLP_MAX_BODY_BYTES:
91+
log.warning(
92+
"Span batch is %s bytes, over the %s byte ingestion limit; not sending it",
93+
len(serialized),
94+
OTLP_MAX_BODY_BYTES,
95+
)
96+
return TOO_LARGE_LOCALLY
97+
98+
url = remove_trailing_slash(client.host) + TRACES_PATH
99+
timeout = getattr(client, "timeout", 15) or 15
100+
try:
101+
response = _get_session().post(
102+
url,
103+
data=gzip.compress(serialized),
104+
headers={
105+
"Content-Type": "application/json",
106+
"Content-Encoding": "gzip",
107+
"Authorization": "Bearer {}".format(client.api_key),
108+
"User-Agent": USER_AGENT,
109+
},
110+
timeout=timeout,
111+
)
112+
except requests.exceptions.RequestException as e:
113+
log.debug("Span batch request failed: %s", e)
114+
return SendOutcome("retry-later")
115+
116+
status = response.status_code
117+
if status < 300:
118+
return OK
119+
if status == 413:
120+
return TOO_LARGE
121+
if status >= 500 or status in _RETRIABLE_STATUSES:
122+
try:
123+
retry_after = parse_retry_after(response.headers.get("Retry-After"))
124+
except Exception:
125+
retry_after = None
126+
return SendOutcome("retry-later", retry_after)
127+
log.error("Failed to send span batch: HTTP %s", status)
128+
return FATAL

0 commit comments

Comments
 (0)