Skip to content

Commit 384a4a8

Browse files
turnipdabeetsclaude
andcommitted
feat(traces): per-span limits and exception stacktraces
Bounds what one span can hold, per the traces spec. A span keeps at most max_attributes_per_span user attributes and max_events_per_span events (128 each, earliest-set wins), and each event at most 128 attributes; what the caps refuse is reported as droppedAttributesCount / droppedEventsCount, clamped to uint32. The posthogDistinctId and sessionId join keys are exempt. max_attribute_value_length (8192) bounds every string an attribute holds, nested ones included, along with span and event names, status messages and resource attributes, so one large value cannot get a span dropped as too large. Recorded exceptions now carry exception.stacktrace, keeping the tail of the traceback where Python puts the raising frame. 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 d51f793 commit 384a4a8

14 files changed

Lines changed: 823 additions & 28 deletions

File tree

posthog/test/tracing/test_config.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import pytest
22

33
from posthog.tracing._config import (
4+
DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH,
5+
DEFAULT_MAX_ATTRIBUTES_PER_SPAN,
6+
DEFAULT_MAX_EVENTS_PER_SPAN,
47
DEFAULT_FLUSH_INTERVAL_SECONDS,
58
DEFAULT_MAX_EXPORT_BATCH_SIZE,
69
DEFAULT_MAX_LIVE_SPANS,
@@ -182,3 +185,39 @@ def __str__(self):
182185
assert resolved.service_name == "api"
183186
assert resolved.resource_attributes["team"] == "x"
184187
assert all(isinstance(key, str) for key in resolved.resource_attributes)
188+
189+
190+
class TestSpanLimitKnobs:
191+
def test_defaults_to_opentelemetrys_counts_and_a_finite_value_length(self):
192+
resolved = resolve_traces_config({})
193+
assert (
194+
resolved.max_attributes_per_span == DEFAULT_MAX_ATTRIBUTES_PER_SPAN == 128
195+
)
196+
assert resolved.max_events_per_span == DEFAULT_MAX_EVENTS_PER_SPAN == 128
197+
assert resolved.max_attribute_value_length == DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH
198+
assert DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH == 8192
199+
200+
def test_honours_explicit_values(self):
201+
resolved = resolve_traces_config(
202+
{
203+
"max_attributes_per_span": 10,
204+
"max_events_per_span": 5,
205+
"max_attribute_value_length": 100,
206+
}
207+
)
208+
assert resolved.max_attributes_per_span == 10
209+
assert resolved.max_events_per_span == 5
210+
assert resolved.max_attribute_value_length == 100
211+
212+
@pytest.mark.parametrize("value", [0, -1, 1.5, "128", None, True])
213+
def test_an_unusable_value_falls_back_rather_than_dropping_every_span(self, value):
214+
resolved = resolve_traces_config(
215+
{
216+
"max_attributes_per_span": value,
217+
"max_events_per_span": value,
218+
"max_attribute_value_length": value,
219+
}
220+
)
221+
assert resolved.max_attributes_per_span == DEFAULT_MAX_ATTRIBUTES_PER_SPAN
222+
assert resolved.max_events_per_span == DEFAULT_MAX_EVENTS_PER_SPAN
223+
assert resolved.max_attribute_value_length == DEFAULT_MAX_ATTRIBUTE_VALUE_LENGTH

posthog/test/tracing/test_export.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -874,3 +874,20 @@ def test_a_forked_child_drops_the_inherited_queue_and_timer(self):
874874
)
875875
pipeline.start_span("child-span").end()
876876
assert [r.name for r in queued(pipeline)] == ["child-span"]
877+
878+
879+
class TestResourceAttributes:
880+
def test_bounds_resource_attributes_on_every_batch(self):
881+
sender = FakeSender(SendOutcome("ok"))
882+
pipeline, _, _ = make_traces(
883+
sender=sender,
884+
max_attribute_value_length=5,
885+
resource_attributes={"team": "platform-infrastructure"},
886+
)
887+
pipeline.start_span("a").end()
888+
pipeline.flush()
889+
resource = {
890+
kv["key"]: kv["value"]
891+
for kv in sender.payloads[0]["resourceSpans"][0]["resource"]["attributes"]
892+
}
893+
assert resource["team"] == {"stringValue": "platf"}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
import pytest
2+
3+
from posthog.tracing._limits import (
4+
bound_attributes,
5+
truncate_attribute_value,
6+
truncate_attributes,
7+
)
8+
from posthog.tracing._otlp import (
9+
CIRCULAR_VALUE,
10+
MAX_VALUE_ITEMS,
11+
MAX_VALUE_NODES,
12+
TRUNCATED_VALUE,
13+
to_any_value,
14+
)
15+
from posthog.tracing._sanitize import UNSERIALIZABLE_VALUE
16+
17+
18+
class TestTruncateAttributeValue:
19+
def test_truncates_a_long_string(self):
20+
assert truncate_attribute_value("x" * 40000, 8192) == "x" * 8192
21+
22+
def test_returns_a_short_string_unchanged(self):
23+
assert truncate_attribute_value("short", 8192) == "short"
24+
25+
@pytest.mark.parametrize("value", [42, 1.5, True, None, 2**70])
26+
def test_leaves_numbers_booleans_and_none_alone(self, value):
27+
assert truncate_attribute_value(value, 3) == value
28+
29+
def test_reaches_strings_nested_in_mappings_and_lists(self):
30+
value = {"body": "x" * 40000, "items": ["y" * 20, {"deep": "z" * 20}]}
31+
assert truncate_attribute_value(value, 8) == {
32+
"body": "x" * 8,
33+
"items": ["y" * 8, {"deep": "z" * 8}],
34+
}
35+
36+
def test_does_not_mutate_the_callers_value(self):
37+
value = {"body": "x" * 20}
38+
truncate_attribute_value(value, 4)
39+
assert value == {"body": "x" * 20}
40+
41+
def test_a_self_referencing_value_terminates_with_the_encoders_marker(self):
42+
value: dict = {"name": "n" * 20}
43+
value["self"] = value
44+
assert truncate_attribute_value(value, 4) == {
45+
"name": "nnnn",
46+
"self": CIRCULAR_VALUE,
47+
}
48+
49+
def test_siblings_sharing_one_object_are_not_a_cycle(self):
50+
shared = {"k": "v" * 10}
51+
assert truncate_attribute_value([shared, shared], 2) == [
52+
{"k": "vv"},
53+
{"k": "vv"},
54+
]
55+
56+
def test_marks_items_past_the_encoders_item_cap(self):
57+
bounded = truncate_attribute_value(["a"] * (MAX_VALUE_ITEMS + 5), 8)
58+
assert len(bounded) == MAX_VALUE_ITEMS + 1
59+
assert bounded[-1] == TRUNCATED_VALUE
60+
# The encoder emits the same shape it would have for the original.
61+
assert to_any_value(bounded) == to_any_value(["a"] * (MAX_VALUE_ITEMS + 5))
62+
63+
def test_stringifies_and_bounds_a_type_the_encoder_would_stringify(self):
64+
class Big:
65+
def __str__(self):
66+
return "b" * 100
67+
68+
assert truncate_attribute_value(Big(), 10) == "b" * 10
69+
assert truncate_attribute_value(b"\x00" * 100, 10) == "b'\\x00\\x00"
70+
71+
def test_a_key_the_encoder_skips_does_not_spend_the_walks_budget(self):
72+
# The encoder drops "" without charging for its value, so the walk must
73+
# too, or "x" would ship unbounded once the walk's budget ran out.
74+
value = {"": list(range(999)), "a": [list(range(999))] * 9, "x": "A" * 1000}
75+
bounded = truncate_attribute_value(value, 100)
76+
assert "" not in bounded
77+
assert bounded["x"] == "A" * 100
78+
encoded = to_any_value(bounded)["kvlistValue"]["values"]
79+
x = next(kv for kv in encoded if kv["key"] == "x")
80+
assert len(x["value"]["stringValue"]) == 100
81+
82+
def test_a_raising_str_costs_only_that_value(self):
83+
class Hostile:
84+
def __str__(self):
85+
raise RuntimeError("no")
86+
87+
assert truncate_attribute_value({"a": Hostile(), "b": "ok"}, 8) == {
88+
"a": UNSERIALIZABLE_VALUE,
89+
"b": "ok",
90+
}
91+
92+
def test_a_raising_accessor_costs_only_that_key(self):
93+
class Explosive(dict):
94+
def __getitem__(self, key):
95+
if key == "bad":
96+
raise RuntimeError("no")
97+
return super().__getitem__(key)
98+
99+
assert truncate_attribute_value(Explosive(good="g" * 9, bad=1), 3) == {
100+
"good": "ggg",
101+
"bad": UNSERIALIZABLE_VALUE,
102+
}
103+
104+
105+
class TestBoundAttributes:
106+
def test_keeps_the_earliest_entries_and_counts_the_rest(self):
107+
source = {f"k{i}": i for i in range(130)}
108+
attributes, dropped = bound_attributes(source, 128, 8192)
109+
assert list(attributes) == [f"k{i}" for i in range(128)]
110+
assert dropped == 2
111+
112+
def test_a_none_value_spends_no_slot(self):
113+
attributes, dropped = bound_attributes({"a": None, "b": 1, "c": 2}, 2, 8)
114+
assert attributes == {"b": 1, "c": 2}
115+
assert dropped == 0
116+
117+
def test_bounds_each_value(self):
118+
attributes, _ = bound_attributes({"a": "x" * 20}, 2, 5)
119+
assert attributes == {"a": "xxxxx"}
120+
121+
def test_a_non_mapping_yields_nothing(self):
122+
assert bound_attributes(["a"], 2, 5) == ({}, 0)
123+
124+
125+
class TestTruncateAttributes:
126+
def test_bounds_every_value_as_a_copy(self):
127+
source = {"service.name": "api", "blob": "x" * 20}
128+
assert truncate_attributes(source, 4) == {"service.name": "api", "blob": "xxxx"}
129+
assert source["blob"] == "x" * 20
130+
131+
132+
class TestWalkBounds:
133+
def test_walks_no_more_strings_than_the_encoder_would_emit(self):
134+
# A thousand paths to one shared list of a thousand strings. Leaves are
135+
# charged against the node budget, as in the encoder, so the walk does
136+
# not copy every string on every path.
137+
inner = ["x" * 50] * 1000
138+
bounded = truncate_attribute_value([inner] * 1000, 8)
139+
walked = sum(
140+
1
141+
for items in bounded
142+
if items is not inner
143+
for item in items
144+
if item == "x" * 8
145+
)
146+
assert 0 < walked <= MAX_VALUE_NODES
147+
148+
def test_stops_walking_a_mapping_at_the_encoders_item_cap(self):
149+
value = {f"k{i}": "v" * 50 for i in range(MAX_VALUE_ITEMS + 50)}
150+
bounded = truncate_attribute_value(value, 4)
151+
assert len(bounded) == MAX_VALUE_ITEMS

posthog/test/tracing/test_otlp.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,39 @@ def test_marks_a_root_span_as_known_not_remote(self):
266266
def test_marks_a_header_parent_as_remote(self):
267267
assert build_otlp_span(record(parent_is_remote=True))["flags"] == 0x301
268268

269+
def test_omits_dropped_counts_when_nothing_was_dropped(self):
270+
span = build_otlp_span(record(events=[SpanEventRecord("e", START_NS)]))
271+
assert "droppedAttributesCount" not in span
272+
assert "droppedEventsCount" not in span
273+
assert "droppedAttributesCount" not in span["events"][0]
274+
275+
def test_emits_dropped_counts_on_the_span_and_its_events(self):
276+
span = build_otlp_span(
277+
record(
278+
dropped_attributes_count=2,
279+
dropped_events_count=3,
280+
events=[SpanEventRecord("e", START_NS, {"k": 1}, 4)],
281+
)
282+
)
283+
assert span["droppedAttributesCount"] == 2
284+
assert span["droppedEventsCount"] == 3
285+
assert span["events"][0]["droppedAttributesCount"] == 4
286+
287+
@pytest.mark.parametrize(
288+
"value,expected",
289+
[
290+
(2**40, 0xFFFFFFFF),
291+
(-1, 0),
292+
(1.9, 1),
293+
("3", 0),
294+
(True, 0),
295+
(float("inf"), 0),
296+
],
297+
)
298+
def test_clamps_a_dropped_count_to_uint32(self, value, expected):
299+
span = build_otlp_span(record(dropped_attributes_count=value))
300+
assert span.get("droppedAttributesCount", 0) == expected
301+
269302
def test_propagates_an_inbound_sampled_out_flag(self):
270303
assert build_otlp_span(record(trace_flags="00"))["flags"] == 0x100
271304

posthog/test/tracing/test_pipeline.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,16 @@
1010
from posthog.test.tracing.helpers import (
1111
SPAN_ID,
1212
TRACE_ID,
13+
FakeSender,
1314
clock,
1415
fake_timers,
1516
make,
17+
make_traces,
1618
queued,
1719
)
1820
from posthog.tracing import _span as span_module
1921
from posthog.tracing._drops import DropLog
22+
from posthog.tracing._transport import SendOutcome
2023
from posthog.tracing._span import NOOP_SPAN, PassThroughSpan, RecordingSpan
2124

2225
__all__ = ["clock", "fake_timers"]
@@ -375,6 +378,17 @@ def test_lets_user_attributes_win_on_collision(self):
375378
pipeline.start_span("a", attributes={"posthogDistinctId": "override"}).end()
376379
assert queued(pipeline)[0].attributes["posthogDistinctId"] == "override"
377380

381+
def test_the_join_keys_survive_a_span_at_its_attribute_cap(self):
382+
pipeline, _, _ = make(
383+
context={"distinct_id": "user-1", "session_id": "sess-1"},
384+
max_attributes_per_span=2,
385+
)
386+
pipeline.start_span("a", attributes={"x": 1, "y": 2, "z": 3}).end()
387+
record = queued(pipeline)[0]
388+
assert record.attributes["posthogDistinctId"] == "user-1"
389+
assert record.attributes["sessionId"] == "sess-1"
390+
assert record.dropped_attributes_count == 1
391+
378392
def test_still_records_the_span_when_reading_context_raises(self):
379393
pipeline, _, _ = make()
380394
pipeline._get_context = mock.Mock(side_effect=RuntimeError("no context"))
@@ -491,3 +505,29 @@ def test_reinit_after_fork_replaces_locks_without_acquiring_them(self):
491505
pipeline.reinit_after_fork()
492506
assert not pipeline._lock.locked()
493507
pipeline.start_span("a").end()
508+
509+
510+
class TestLimitsReachTheExport:
511+
def test_bounds_names_and_attributes_with_the_configured_length(self):
512+
sender = FakeSender(SendOutcome("ok"))
513+
pipeline, _, _ = make_traces(sender=sender, max_attribute_value_length=5)
514+
pipeline.start_span("a long name", attributes={"k": "a long value"}).end()
515+
pipeline.flush()
516+
(span,) = sender.batches()[0]
517+
assert span["name"] == "a lon"
518+
assert span["attributes"] == [{"key": "k", "value": {"stringValue": "a lon"}}]
519+
520+
def test_reports_a_spans_limit_drops_once_at_debug(self, caplog):
521+
caplog.set_level("DEBUG", logger="posthog")
522+
pipeline, _, _ = make(max_attributes_per_span=1, max_events_per_span=1)
523+
span = pipeline.start_span("capped", attributes={"a": 1, "b": 2})
524+
span.add_event("e1", {"k": 1}).add_event("e2")
525+
span.end()
526+
messages = [
527+
r.getMessage() for r in caplog.records if "Span limits" in r.getMessage()
528+
]
529+
assert len(messages) == 1
530+
assert messages[0].endswith(
531+
'Span limits discarded data from "capped": 1 attributes, 1 events, '
532+
"0 event attributes"
533+
)

0 commit comments

Comments
 (0)