Skip to content

Commit 3c1c3d2

Browse files
turnipdabeetsclaude
andcommitted
feat(traces): span pipeline
Adds span creation and the end-of-span gates. PostHogTraces resolves a span's parent (an explicit traceparent string or handle, else the active span, else a new trace), attaches the posthogDistinctId and sessionId join keys from the request context, bounds live spans by count and by age so a leak cannot disable tracing, and hands each ended span to an exporter unless the client was disabled. The `traces` option is validated key by key, falling back to the documented default with a warning. Dropped spans are counted per reason and reported at most once per flush interval. The export queue arrives in the next change; this one runs against a stand-in. 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 1f1d603 commit 3c1c3d2

6 files changed

Lines changed: 1241 additions & 0 deletions

File tree

posthog/test/tracing/helpers.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Shared fakes for the tracing pipeline tests."""
2+
3+
import threading
4+
import time
5+
from contextvars import ContextVar
6+
from types import SimpleNamespace
7+
from unittest import mock
8+
9+
import pytest
10+
11+
from posthog.tracing._config import resolve_traces_config
12+
from posthog.tracing._drops import DropLog
13+
from posthog.tracing._pipeline import PostHogTraces
14+
15+
TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736"
16+
SPAN_ID = "00f067aa0ba902b7"
17+
18+
19+
class FakeTimer:
20+
"""Records the delay it was armed with; fires only when a test says so."""
21+
22+
instances: list = []
23+
24+
def __init__(self, delay, fn):
25+
self.delay = delay
26+
self.fn = fn
27+
self.daemon = False
28+
self.started = False
29+
self.cancelled = False
30+
FakeTimer.instances.append(self)
31+
32+
def start(self):
33+
self.started = True
34+
35+
def cancel(self):
36+
self.cancelled = True
37+
38+
def fire(self):
39+
self.fn()
40+
41+
42+
class RecordingExporter:
43+
"""Stands in for the export queue: keeps every record it is handed."""
44+
45+
def __init__(self):
46+
self.records: list = []
47+
self.closed = False
48+
self.reinitialized = False
49+
50+
def enqueue(self, record):
51+
self.records.append(record)
52+
53+
def flush(self, timeout=None):
54+
pass
55+
56+
def close(self):
57+
self.closed = True
58+
59+
def warn_if_queued(self):
60+
pass
61+
62+
def reinit_after_fork(self):
63+
self.reinitialized = True
64+
65+
66+
@pytest.fixture(autouse=True)
67+
def fake_timers():
68+
FakeTimer.instances = []
69+
with mock.patch.object(threading, "Timer", FakeTimer):
70+
yield FakeTimer
71+
72+
73+
@pytest.fixture
74+
def clock():
75+
state = {"now": 1000.0}
76+
with mock.patch.object(time, "monotonic", lambda: state["now"]):
77+
yield state
78+
79+
80+
def make(client=None, context=None, **config):
81+
"""A pipeline whose ended spans collect on a ``RecordingExporter``."""
82+
client = client or SimpleNamespace(disabled=False, send=True)
83+
exporter = RecordingExporter()
84+
active: ContextVar = ContextVar("active", default=None)
85+
resolved = resolve_traces_config(config)
86+
drops = DropLog(resolved.flush_interval)
87+
pipeline = PostHogTraces(
88+
client, resolved, lambda: context or {}, active, exporter, drops
89+
)
90+
return pipeline, exporter, active
91+
92+
93+
def queued(pipeline):
94+
return pipeline._exporter.records
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
import pytest
2+
3+
from posthog.tracing._config import (
4+
DEFAULT_FLUSH_INTERVAL_SECONDS,
5+
DEFAULT_MAX_EXPORT_BATCH_SIZE,
6+
DEFAULT_MAX_LIVE_SPANS,
7+
DEFAULT_MAX_QUEUE_SIZE,
8+
DEFAULT_MAX_SPAN_AGE_SECONDS,
9+
ResolvedTracesConfig,
10+
resolve_traces_config,
11+
)
12+
13+
14+
class TestDefaults:
15+
def test_applies_the_documented_defaults(self):
16+
assert resolve_traces_config({}) == ResolvedTracesConfig(
17+
flush_interval=DEFAULT_FLUSH_INTERVAL_SECONDS,
18+
max_export_batch_size=DEFAULT_MAX_EXPORT_BATCH_SIZE,
19+
max_queue_size=DEFAULT_MAX_QUEUE_SIZE,
20+
max_live_spans=DEFAULT_MAX_LIVE_SPANS,
21+
max_span_age=DEFAULT_MAX_SPAN_AGE_SECONDS,
22+
)
23+
24+
def test_leaves_service_name_unset_so_the_encoder_supplies_unknown_service(self):
25+
assert resolve_traces_config({}).service_name is None
26+
27+
@pytest.mark.parametrize("config", [None, "nope", 42, ["a"]])
28+
def test_a_non_dict_config_falls_back_to_defaults(self, config):
29+
assert resolve_traces_config(config) == resolve_traces_config({})
30+
31+
32+
class TestExplicitValues:
33+
def test_honours_explicit_values(self):
34+
resolved = resolve_traces_config(
35+
{
36+
"service_name": "api",
37+
"service_version": "1.2.3",
38+
"environment": "prod",
39+
"flush_interval": 2,
40+
"max_export_batch_size": 100,
41+
"max_queue_size": 400,
42+
"max_live_spans": 50,
43+
"max_span_age": 60,
44+
}
45+
)
46+
assert resolved == ResolvedTracesConfig(
47+
service_name="api",
48+
service_version="1.2.3",
49+
environment="prod",
50+
flush_interval=2.0,
51+
max_export_batch_size=100,
52+
max_queue_size=400,
53+
max_live_spans=50,
54+
max_span_age=60.0,
55+
)
56+
57+
@pytest.mark.parametrize(
58+
"value",
59+
[0, -1, 0.5, 512.7, float("nan"), float("inf"), "512", True, None],
60+
)
61+
def test_falls_back_for_an_unusable_batch_size(self, value):
62+
assert (
63+
resolve_traces_config(
64+
{"max_export_batch_size": value}
65+
).max_export_batch_size
66+
== DEFAULT_MAX_EXPORT_BATCH_SIZE
67+
)
68+
69+
def test_accepts_a_whole_number_float_batch_size(self):
70+
assert (
71+
resolve_traces_config(
72+
{"max_export_batch_size": 100.0}
73+
).max_export_batch_size
74+
== 100
75+
)
76+
77+
def test_an_unusable_knob_keeps_the_rest_of_the_config(self):
78+
resolved = resolve_traces_config(
79+
{"service_name": "api", "max_live_spans": float("inf")}
80+
)
81+
assert resolved.service_name == "api"
82+
assert resolved.max_live_spans == DEFAULT_MAX_LIVE_SPANS
83+
84+
@pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf"), "5", False])
85+
def test_falls_back_for_an_unusable_flush_interval(self, value):
86+
assert (
87+
resolve_traces_config({"flush_interval": value}).flush_interval
88+
== DEFAULT_FLUSH_INTERVAL_SECONDS
89+
)
90+
91+
@pytest.mark.parametrize("value", [0, -1, float("nan"), float("inf")])
92+
def test_falls_back_for_unusable_live_span_bounds(self, value):
93+
resolved = resolve_traces_config(
94+
{"max_live_spans": value, "max_span_age": value}
95+
)
96+
assert resolved.max_live_spans == DEFAULT_MAX_LIVE_SPANS
97+
assert resolved.max_span_age == DEFAULT_MAX_SPAN_AGE_SECONDS
98+
99+
def test_keeps_the_queue_at_least_as_large_as_the_export_batch(self):
100+
resolved = resolve_traces_config({"max_export_batch_size": 4096})
101+
assert resolved.max_queue_size == 4096
102+
103+
def test_floors_an_explicit_queue_size_at_the_batch_size(self):
104+
resolved = resolve_traces_config(
105+
{"max_export_batch_size": 10, "max_queue_size": 3}
106+
)
107+
assert resolved.max_queue_size == 10
108+
109+
def test_ignores_a_non_string_named_field(self):
110+
assert resolve_traces_config({"service_name": 42}).service_name is None
111+
112+
113+
class TestResourceAttributes:
114+
def test_lets_otlp_resource_attributes_override_the_named_fields(self):
115+
resolved = resolve_traces_config(
116+
{
117+
"service_name": "named",
118+
"resource_attributes": {"service.name": "from-attrs", "region": "eu"},
119+
}
120+
)
121+
assert resolved.service_name == "from-attrs"
122+
assert resolved.resource_attributes == {
123+
"service.name": "from-attrs",
124+
"region": "eu",
125+
}
126+
127+
def test_attaches_host_attributes_and_lets_user_attributes_override_them(self):
128+
resolved = resolve_traces_config(
129+
{"resource_attributes": {"os.name": "Custom"}},
130+
{"os.name": "Linux", "os.version": "6.1"},
131+
)
132+
assert resolved.resource_attributes == {
133+
"os.name": "Custom",
134+
"os.version": "6.1",
135+
}
136+
137+
def test_ignores_a_non_dict_value(self):
138+
assert (
139+
resolve_traces_config({"resource_attributes": ["a"]}).resource_attributes
140+
== {}
141+
)
142+
143+
def test_drops_an_identity_key_that_is_not_a_string(self):
144+
resolved = resolve_traces_config(
145+
{
146+
"service_name": "named",
147+
"resource_attributes": {
148+
"service.name": 42,
149+
"deployment.environment": 1,
150+
},
151+
}
152+
)
153+
assert resolved.service_name == "named"
154+
assert resolved.environment is None
155+
assert "service.name" not in resolved.resource_attributes
156+
157+
def test_keeps_the_readable_attributes_when_one_accessor_raises(self):
158+
class Explosive(dict):
159+
def __getitem__(self, key):
160+
if key == "bad":
161+
raise RuntimeError("boom")
162+
return super().__getitem__(key)
163+
164+
resolved = resolve_traces_config(
165+
{"resource_attributes": Explosive(good=1, bad=2)}
166+
)
167+
assert resolved.resource_attributes == {"good": 1}
168+
169+
170+
class TestHostileResourceAttributeKeys:
171+
def test_drops_only_a_key_that_cannot_be_stringified(self):
172+
class HostileKey:
173+
def __str__(self):
174+
raise RuntimeError("no")
175+
176+
resolved = resolve_traces_config(
177+
{
178+
"service_name": "api",
179+
"resource_attributes": {HostileKey(): 1, "team": "x"},
180+
}
181+
)
182+
assert resolved.service_name == "api"
183+
assert resolved.resource_attributes["team"] == "x"
184+
assert all(isinstance(key, str) for key in resolved.resource_attributes)

0 commit comments

Comments
 (0)