From 9ffa2a59bebfbd4b5d5ebf91795aa97b14c72f05 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 3 Sep 2026 16:58:59 -0400 Subject: [PATCH 1/4] feat(traces): W3C trace context ids and traceparent parsing Starts the posthog.tracing package with W3C Trace Context: random 16-byte trace ids and 8-byte span ids (never all zeros), and traceparent/tracestate parsing per the spec. A header with uppercase hex, version ff, or a version 00 header with trailing fields is invalid; a higher version is echoed whole. Only the sampled flag is kept. A tracestate with more than 32 members or non-printable characters is discarded, and one over 512 characters is trimmed by whole members. Not reachable from the client. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01TkZAsCciW4PV8ZdcCHmAbA --- posthog/test/tracing/__init__.py | 0 posthog/test/tracing/test_ids.py | 74 +++++++++ posthog/test/tracing/test_traceparent.py | 184 +++++++++++++++++++++++ posthog/tracing/__init__.py | 3 + posthog/tracing/_ids.py | 51 +++++++ posthog/tracing/_traceparent.py | 141 +++++++++++++++++ pyproject.toml | 2 + references/public_api_snapshot.txt | 1 + 8 files changed, 456 insertions(+) create mode 100644 posthog/test/tracing/__init__.py create mode 100644 posthog/test/tracing/test_ids.py create mode 100644 posthog/test/tracing/test_traceparent.py create mode 100644 posthog/tracing/__init__.py create mode 100644 posthog/tracing/_ids.py create mode 100644 posthog/tracing/_traceparent.py diff --git a/posthog/test/tracing/__init__.py b/posthog/test/tracing/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/posthog/test/tracing/test_ids.py b/posthog/test/tracing/test_ids.py new file mode 100644 index 000000000..b276eabe1 --- /dev/null +++ b/posthog/test/tracing/test_ids.py @@ -0,0 +1,74 @@ +import re +from unittest import mock + +import pytest + +from posthog.tracing import _ids +from posthog.tracing._ids import ( + is_valid_span_id, + is_valid_trace_id, + new_span_id, + new_trace_id, +) + +LOWER_HEX = re.compile(r"^[0-9a-f]+$") + + +class TestNewTraceId: + def test_is_32_lowercase_hex_characters(self): + trace_id = new_trace_id() + assert len(trace_id) == 32 + assert LOWER_HEX.match(trace_id) + + def test_is_never_all_zeros(self): + with mock.patch.object( + _ids.secrets, "token_hex", side_effect=["0" * 32, "0" * 32, "ab" * 16] + ): + assert new_trace_id() == "ab" * 16 + + def test_does_not_repeat(self): + assert len({new_trace_id() for _ in range(1000)}) == 1000 + + +class TestNewSpanId: + def test_is_16_lowercase_hex_characters(self): + span_id = new_span_id() + assert len(span_id) == 16 + assert LOWER_HEX.match(span_id) + + def test_is_never_all_zeros(self): + with mock.patch.object( + _ids.secrets, "token_hex", side_effect=["0" * 16, "cd" * 8] + ): + assert new_span_id() == "cd" * 8 + + def test_does_not_repeat(self): + assert len({new_span_id() for _ in range(1000)}) == 1000 + + +class TestValidation: + @pytest.mark.parametrize( + "value,expected", + [ + ("4bf92f3577b34da6a3ce929d0e0e4736", True), + ("0" * 32, False), + ("abc", False), + ("4BF92F3577B34DA6A3CE929D0E0E4736", False), + ("zz" * 16, False), + (12345, False), + (None, False), + ], + ) + def test_is_valid_trace_id(self, value, expected): + assert is_valid_trace_id(value) is expected + + @pytest.mark.parametrize( + "value,expected", + [ + ("00f067aa0ba902b7", True), + ("0" * 16, False), + ("4bf92f3577b34da6a3ce929d0e0e4736", False), + ], + ) + def test_is_valid_span_id(self, value, expected): + assert is_valid_span_id(value) is expected diff --git a/posthog/test/tracing/test_traceparent.py b/posthog/test/tracing/test_traceparent.py new file mode 100644 index 000000000..351d43ad8 --- /dev/null +++ b/posthog/test/tracing/test_traceparent.py @@ -0,0 +1,184 @@ +import pytest + +from posthog.tracing._traceparent import ( + RemoteSpanContext, + format_traceparent, + normalize_traceparent, + parse_traceparent, + sanitize_tracestate, + traceparent_header, +) + +TRACE_ID = "4bf92f3577b34da6a3ce929d0e0e4736" +SPAN_ID = "00f067aa0ba902b7" + + +class TestParseTraceparent: + def test_parses_a_sampled_header(self): + assert parse_traceparent(f"00-{TRACE_ID}-{SPAN_ID}-01") == RemoteSpanContext( + TRACE_ID, SPAN_ID, "01" + ) + + def test_continues_a_sampled_out_trace_and_keeps_the_flag(self): + assert parse_traceparent(f"00-{TRACE_ID}-{SPAN_ID}-00") == RemoteSpanContext( + TRACE_ID, SPAN_ID, "00" + ) + + @pytest.mark.parametrize( + "inbound,expected", [("05", "01"), ("04", "00"), ("ff", "01")] + ) + def test_zeroes_flags_version_00_does_not_define(self, inbound, expected): + parsed = parse_traceparent(f"00-{TRACE_ID}-{SPAN_ID}-{inbound}") + assert parsed is not None + assert parsed.flags == expected + + def test_accepts_a_future_version_with_extra_fields(self): + parsed = parse_traceparent(f"01-{TRACE_ID}-{SPAN_ID}-01-extra") + assert parsed == RemoteSpanContext(TRACE_ID, SPAN_ID, "01") + + def test_ignores_surrounding_whitespace(self): + parsed = parse_traceparent(f" 00-{TRACE_ID}-{SPAN_ID}-01 ") + assert parsed == RemoteSpanContext(TRACE_ID, SPAN_ID, "01") + + @pytest.mark.parametrize( + "value", + [ + f"00-{TRACE_ID.upper()}-{SPAN_ID}-01", + f"00-{TRACE_ID}-{SPAN_ID.upper()}-01", + f"00-{TRACE_ID}-{SPAN_ID}-0A", + ], + ) + def test_rejects_uppercase_rather_than_folding_it(self, value): + assert parse_traceparent(value) is None + + def test_rejects_a_version_00_header_with_trailing_fields(self): + assert parse_traceparent(f"00-{TRACE_ID}-{SPAN_ID}-01-extra") is None + + @pytest.mark.parametrize( + "value", + [ + "garbage", + "", + f"ff-{TRACE_ID}-{SPAN_ID}-01", + f"00-{'0' * 32}-{SPAN_ID}-01", + f"00-{TRACE_ID}-{'0' * 16}-01", + f"00-{TRACE_ID[:30]}-{SPAN_ID}-01", + f"00-{TRACE_ID}-{SPAN_ID}", + 42, + None, + [f"00-{TRACE_ID}-{SPAN_ID}-01"], + ], + ) + def test_returns_none_for_malformed_input(self, value): + assert parse_traceparent(value) is None + + +class TestFormatTraceparent: + def test_sets_the_sampled_flag_on_a_trace_started_here(self): + assert format_traceparent(TRACE_ID, SPAN_ID) == f"00-{TRACE_ID}-{SPAN_ID}-01" + + def test_propagates_the_flags_byte_it_was_given(self): + assert ( + format_traceparent(TRACE_ID, SPAN_ID, "00") == f"00-{TRACE_ID}-{SPAN_ID}-00" + ) + + def test_round_trips_through_the_parser(self): + assert parse_traceparent(format_traceparent(TRACE_ID, SPAN_ID, "00")) == ( + RemoteSpanContext(TRACE_ID, SPAN_ID, "00") + ) + + +class TestNormalizeTraceparent: + def test_carries_version_and_flags_through_as_received(self): + assert ( + normalize_traceparent(f"01-{TRACE_ID}-{SPAN_ID}-05") + == f"01-{TRACE_ID}-{SPAN_ID}-05" + ) + + def test_echoes_a_higher_version_whole_including_its_trailing_fields(self): + assert ( + normalize_traceparent(f" 01-{TRACE_ID}-{SPAN_ID}-01-what ") + == f"01-{TRACE_ID}-{SPAN_ID}-01-what" + ) + + @pytest.mark.parametrize( + "value", + [ + "garbage", + f"ff-{TRACE_ID}-{SPAN_ID}-01", + f"00-{'0' * 32}-{SPAN_ID}-01", + f"00-{TRACE_ID.upper()}-{SPAN_ID}-01", + f"00-{TRACE_ID}-{SPAN_ID}-01-extra", + f"01-{TRACE_ID}-{SPAN_ID}-01-x\rX-Injected: yes", + f"01-{TRACE_ID}-{SPAN_ID}-01-caf\u00e9", + f"01-{TRACE_ID}-{SPAN_ID}-01-" + "x" * 512, + ["a", "b"], + ], + ) + def test_rejects_malformed_input(self, value): + assert normalize_traceparent(value) is None + + +class TestTraceparentHeader: + def test_unwraps_a_one_element_list(self): + header = f"00-{TRACE_ID}-{SPAN_ID}-01" + assert traceparent_header([header]) == header + assert traceparent_header((header,)) == header + + def test_leaves_two_values_for_the_parser_to_reject(self): + values = [f"00-{TRACE_ID}-{SPAN_ID}-01", f"00-{TRACE_ID}-{SPAN_ID}-00"] + assert traceparent_header(values) == values + assert parse_traceparent(traceparent_header(values)) is None + + def test_passes_a_string_through(self): + assert traceparent_header("x") == "x" + + +class TestSanitizeTracestate: + def test_preserves_a_valid_vendor_list_unchanged(self): + assert sanitize_tracestate("vendor=abc,other=def") == "vendor=abc,other=def" + + def test_trims_surrounding_whitespace(self): + assert sanitize_tracestate(" vendor=abc ") == "vendor=abc" + + def test_keeps_a_tab_separated_vendor_list(self): + assert sanitize_tracestate("vendor=abc,\tother=def") == "vendor=abc,\tother=def" + + def test_tolerates_empty_members(self): + assert sanitize_tracestate("vendor=abc,,other=def") == "vendor=abc,,other=def" + + @pytest.mark.parametrize( + "value", + [ + "", + " ", + "novalue", + "vendor=abc,novalue", + 42, + None, + ",".join(f"k{i}=v" for i in range(33)), + # One member, over 512 characters: nothing survives the trim. + "k=" + "v" * 600, + "vendor=abc\r\nother=def", + "vendor=\ud800", + "vendor=é", + ], + ) + def test_discards_invalid_values(self, value): + assert sanitize_tracestate(value) is None + + def test_trims_an_over_long_value_by_dropping_large_members_first(self): + # The spec's scenario: 600 characters, one member of 150. Dropping that + # member leaves 449 characters, kept unchanged. + large = "big=" + "x" * 146 + small = ["k{:02d}=".format(i) + "v" * 13 for i in range(25)] + value = ",".join(small[:10] + [large] + small[10:]) + assert len(large) == 150 and len(value) == 600 + trimmed = sanitize_tracestate(value) + assert trimmed == ",".join(small) + assert len(trimmed) == 449 + + def test_trims_from_the_right_once_no_large_member_is_left(self): + members = ["k{:02d}=".format(i) + "v" * 60 for i in range(10)] + # Ten 64-character members; seven fit in 512. + assert sanitize_tracestate(",".join(members)) == ",".join(members[:7]) diff --git a/posthog/tracing/__init__.py b/posthog/tracing/__init__.py new file mode 100644 index 000000000..7d17992ef --- /dev/null +++ b/posthog/tracing/__init__.py @@ -0,0 +1,3 @@ +"""Distributed tracing: spans exported as OTLP JSON to PostHog.""" + +__all__: list[str] = [] diff --git a/posthog/tracing/_ids.py b/posthog/tracing/_ids.py new file mode 100644 index 000000000..96417dc88 --- /dev/null +++ b/posthog/tracing/_ids.py @@ -0,0 +1,51 @@ +"""W3C Trace Context identifiers: 16-byte trace ids and 8-byte span ids, lowercase hex. + +The ingestion service zeroes an id of the wrong length rather than rejecting +it, which silently orphans the span, so every id is validated before it ships. +""" + +import re +import secrets + +TRACE_ID_BYTES = 16 +SPAN_ID_BYTES = 8 + +TRACE_ID_HEX = TRACE_ID_BYTES * 2 +SPAN_ID_HEX = SPAN_ID_BYTES * 2 + +INVALID_TRACE_ID = "0" * TRACE_ID_HEX +INVALID_SPAN_ID = "0" * SPAN_ID_HEX + +_HEX_RE = re.compile(r"^[0-9a-f]+$") + + +def _random_hex_id(byte_length: int) -> str: + hex_id = secrets.token_hex(byte_length) + while hex_id == "0" * (byte_length * 2): + hex_id = secrets.token_hex(byte_length) + return hex_id + + +def new_trace_id() -> str: + return _random_hex_id(TRACE_ID_BYTES) + + +def new_span_id() -> str: + return _random_hex_id(SPAN_ID_BYTES) + + +def _is_valid_hex_id(value: object, length: int, invalid: str) -> bool: + return ( + isinstance(value, str) + and len(value) == length + and value != invalid + and _HEX_RE.match(value) is not None + ) + + +def is_valid_trace_id(value: object) -> bool: + return _is_valid_hex_id(value, TRACE_ID_HEX, INVALID_TRACE_ID) + + +def is_valid_span_id(value: object) -> bool: + return _is_valid_hex_id(value, SPAN_ID_HEX, INVALID_SPAN_ID) diff --git a/posthog/tracing/_traceparent.py b/posthog/tracing/_traceparent.py new file mode 100644 index 000000000..10420d569 --- /dev/null +++ b/posthog/tracing/_traceparent.py @@ -0,0 +1,141 @@ +"""W3C Trace Context interop: ``traceparent`` and ``tracestate`` handling.""" + +import re +from dataclasses import dataclass +from typing import List, Optional + +from ._ids import is_valid_span_id, is_valid_trace_id + +TRACE_FLAGS_SAMPLED = "01" +TRACE_FLAGS_UNSAMPLED = "00" + +# A version above `00` may append fields after the first four. +_TRACEPARENT_RE = re.compile( + r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$" +) + +# W3C sets no bound; the tracestate cap keeps a peer's header from being +# amplified onto every outbound call. +_TRACEPARENT_MAX_LENGTH = 512 +_TRACESTATE_MAX_MEMBERS = 32 +_TRACESTATE_MAX_LENGTH = 512 +# W3C: when trimming, drop members longer than this first. +_TRACESTATE_LARGE_MEMBER_LENGTH = 128 +_TRACESTATE_FORBIDDEN_RE = re.compile(r"[^\x20-\x7e\t]") + + +@dataclass(frozen=True) +class RemoteSpanContext: + trace_id: str + span_id: str + # `01` sampled or `00` sampled out; other flag bits are zeroed. + flags: str + + +@dataclass(frozen=True) +class _TraceparentFields: + version: str + trace_id: str + span_id: str + flags: str + + +def _match_traceparent(value: object) -> Optional[_TraceparentFields]: + if not isinstance(value, str): + return None + # Not lowercased: a conformant peer restarts the trace on uppercase hex. + stripped = value.strip() + if len(stripped) > _TRACEPARENT_MAX_LENGTH: + return None + match = _TRACEPARENT_RE.match(stripped) + if match is None: + return None + version, trace_id, span_id, flags, trailing = match.group(1, 2, 3, 4, 5) + if version == "ff" or (version == "00" and trailing): + return None + if trailing and _TRACESTATE_FORBIDDEN_RE.search(trailing): + return None + if not is_valid_trace_id(trace_id) or not is_valid_span_id(span_id): + return None + return _TraceparentFields(version, trace_id, span_id, flags) + + +def parse_traceparent(value: object) -> Optional[RemoteSpanContext]: + """Parse an inbound ``traceparent``; ``None`` for anything malformed. + + A sampled-out (``00``) trace is still continued, and the flag is carried + onward so a downstream sampler sees the caller's decision. + """ + fields = _match_traceparent(value) + if fields is None: + return None + # W3C requires zeroing the flag bits version `00` does not define. + flags = ( + TRACE_FLAGS_SAMPLED if int(fields.flags, 16) & 0x01 else TRACE_FLAGS_UNSAMPLED + ) + return RemoteSpanContext(fields.trace_id, fields.span_id, flags) + + +def normalize_traceparent(value: object) -> Optional[str]: + """The inbound ``traceparent`` as received, or ``None`` when it is malformed. + + Echoed whole rather than rebuilt, so a higher version keeps the fields this + SDK does not read. + """ + if not isinstance(value, str) or _match_traceparent(value) is None: + return None + return value.strip() + + +def traceparent_header(value: object) -> object: + """Unwrap the one-element list a multi-value header API returns. + + A longer list holds two different inbound values and is left to be rejected. + """ + if isinstance(value, (list, tuple)) and len(value) == 1: + return value[0] + return value + + +def format_traceparent( + trace_id: str, span_id: str, flags: str = TRACE_FLAGS_SAMPLED +) -> str: + return f"00-{trace_id}-{span_id}-{flags}" + + +def sanitize_tracestate(value: object) -> Optional[str]: + """Validate an inbound ``tracestate``; ``None`` when it is malformed. + + An invalid value is discarded without invalidating its traceparent. One + over 512 characters is trimmed by whole members. + """ + if not isinstance(value, str): + return None + trimmed = value.strip() + if not trimmed or _TRACESTATE_FORBIDDEN_RE.search(trimmed): + return None + members = trimmed.split(",") + if len(members) > _TRACESTATE_MAX_MEMBERS: + return None + if any(member.strip() and "=" not in member for member in members): + return None + if len(trimmed) <= _TRACESTATE_MAX_LENGTH: + return trimmed + return _trim_to_length(members) + + +def _trim_to_length(members: List[str]) -> Optional[str]: + """Drop large members first, then from the right, keeping those nearest the caller.""" + kept = list(members) + + def joined_length() -> int: + return sum(len(member) for member in kept) + len(kept) - 1 + + for index in range(len(kept) - 1, -1, -1): + if joined_length() <= _TRACESTATE_MAX_LENGTH: + break + if len(kept[index]) > _TRACESTATE_LARGE_MEMBER_LENGTH: + del kept[index] + while kept and joined_length() > _TRACESTATE_MAX_LENGTH: + kept.pop() + return ",".join(kept) if kept else None diff --git a/pyproject.toml b/pyproject.toml index 54ad4b8ad..a4525caaa 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -113,12 +113,14 @@ packages = [ "posthog.ai.claude_agent_sdk", "posthog.ai.otel", "posthog.mcp", + "posthog.tracing", "posthog.test", "posthog.test.ai", "posthog.test.ai.openai_agents", "posthog.test.ai.claude_agent_sdk", "posthog.test.ai.otel", "posthog.test.mcp", + "posthog.test.tracing", "posthog.integrations", ] diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 36b28e434..0650028b3 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -1558,6 +1558,7 @@ module posthog.mcp.version module posthog.metrics_capture module posthog.poller module posthog.request +module posthog.tracing module posthog.types module posthog.utils module posthog.version From eafebf95fed148230c85201173d38faa4eeb82a3 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 15 Sep 2026 10:09:08 -0400 Subject: [PATCH 2/4] fix(traces): reject ids with a trailing newline `$` matches before a final newline, so the length check plus `match` accepted a 31-hex trace id or 15-hex span id followed by `\n`. Use `fullmatch` so the validators enforce their contract. --- posthog/test/tracing/test_ids.py | 2 ++ posthog/tracing/_ids.py | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/posthog/test/tracing/test_ids.py b/posthog/test/tracing/test_ids.py index b276eabe1..ee80fff4e 100644 --- a/posthog/test/tracing/test_ids.py +++ b/posthog/test/tracing/test_ids.py @@ -55,6 +55,7 @@ class TestValidation: ("abc", False), ("4BF92F3577B34DA6A3CE929D0E0E4736", False), ("zz" * 16, False), + ("a" * 31 + "\n", False), (12345, False), (None, False), ], @@ -68,6 +69,7 @@ def test_is_valid_trace_id(self, value, expected): ("00f067aa0ba902b7", True), ("0" * 16, False), ("4bf92f3577b34da6a3ce929d0e0e4736", False), + ("a" * 15 + "\n", False), ], ) def test_is_valid_span_id(self, value, expected): diff --git a/posthog/tracing/_ids.py b/posthog/tracing/_ids.py index 96417dc88..998317290 100644 --- a/posthog/tracing/_ids.py +++ b/posthog/tracing/_ids.py @@ -39,7 +39,7 @@ def _is_valid_hex_id(value: object, length: int, invalid: str) -> bool: isinstance(value, str) and len(value) == length and value != invalid - and _HEX_RE.match(value) is not None + and _HEX_RE.fullmatch(value) is not None ) From 205db65f37cfea8a821b125b267df2cb433c8bb1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 16 Sep 2026 20:58:02 -0400 Subject: [PATCH 3/4] refactor(traces): tighten traceparent parsing per review Ids are valid by construction, so the parser keeps only the all-zero compare and the unused validators go. A bytes header value (a raw ASGI scope) is decoded rather than read as no parent. The length-cap comment and the printable-ASCII regex are named for what they cover. --- posthog/test/tracing/test_ids.py | 34 ----------------------- posthog/test/tracing/test_traceparent.py | 12 ++++++++ posthog/tracing/_ids.py | 23 ++-------------- posthog/tracing/_traceparent.py | 35 ++++++++++++++++-------- 4 files changed, 38 insertions(+), 66 deletions(-) diff --git a/posthog/test/tracing/test_ids.py b/posthog/test/tracing/test_ids.py index ee80fff4e..b677cd9e7 100644 --- a/posthog/test/tracing/test_ids.py +++ b/posthog/test/tracing/test_ids.py @@ -1,12 +1,8 @@ import re from unittest import mock -import pytest - from posthog.tracing import _ids from posthog.tracing._ids import ( - is_valid_span_id, - is_valid_trace_id, new_span_id, new_trace_id, ) @@ -44,33 +40,3 @@ def test_is_never_all_zeros(self): def test_does_not_repeat(self): assert len({new_span_id() for _ in range(1000)}) == 1000 - - -class TestValidation: - @pytest.mark.parametrize( - "value,expected", - [ - ("4bf92f3577b34da6a3ce929d0e0e4736", True), - ("0" * 32, False), - ("abc", False), - ("4BF92F3577B34DA6A3CE929D0E0E4736", False), - ("zz" * 16, False), - ("a" * 31 + "\n", False), - (12345, False), - (None, False), - ], - ) - def test_is_valid_trace_id(self, value, expected): - assert is_valid_trace_id(value) is expected - - @pytest.mark.parametrize( - "value,expected", - [ - ("00f067aa0ba902b7", True), - ("0" * 16, False), - ("4bf92f3577b34da6a3ce929d0e0e4736", False), - ("a" * 15 + "\n", False), - ], - ) - def test_is_valid_span_id(self, value, expected): - assert is_valid_span_id(value) is expected diff --git a/posthog/test/tracing/test_traceparent.py b/posthog/test/tracing/test_traceparent.py index 351d43ad8..3c6b06d43 100644 --- a/posthog/test/tracing/test_traceparent.py +++ b/posthog/test/tracing/test_traceparent.py @@ -40,6 +40,10 @@ def test_ignores_surrounding_whitespace(self): parsed = parse_traceparent(f" 00-{TRACE_ID}-{SPAN_ID}-01 ") assert parsed == RemoteSpanContext(TRACE_ID, SPAN_ID, "01") + def test_decodes_a_raw_asgi_header_value(self): + parsed = parse_traceparent(f"00-{TRACE_ID}-{SPAN_ID}-01".encode("ascii")) + assert parsed == RemoteSpanContext(TRACE_ID, SPAN_ID, "01") + @pytest.mark.parametrize( "value", [ @@ -67,6 +71,7 @@ def test_rejects_a_version_00_header_with_trailing_fields(self): 42, None, [f"00-{TRACE_ID}-{SPAN_ID}-01"], + "00-caf\u00e9".encode("utf-8"), ], ) def test_returns_none_for_malformed_input(self, value): @@ -95,6 +100,10 @@ def test_carries_version_and_flags_through_as_received(self): == f"01-{TRACE_ID}-{SPAN_ID}-05" ) + def test_decodes_a_raw_asgi_header_value(self): + header = f"00-{TRACE_ID}-{SPAN_ID}-01" + assert normalize_traceparent(header.encode("ascii")) == header + def test_echoes_a_higher_version_whole_including_its_trailing_fields(self): assert ( normalize_traceparent(f" 01-{TRACE_ID}-{SPAN_ID}-01-what ") @@ -138,6 +147,9 @@ class TestSanitizeTracestate: def test_preserves_a_valid_vendor_list_unchanged(self): assert sanitize_tracestate("vendor=abc,other=def") == "vendor=abc,other=def" + def test_decodes_a_raw_asgi_header_value(self): + assert sanitize_tracestate(b"vendor=abc") == "vendor=abc" + def test_trims_surrounding_whitespace(self): assert sanitize_tracestate(" vendor=abc ") == "vendor=abc" diff --git a/posthog/tracing/_ids.py b/posthog/tracing/_ids.py index 998317290..7e8858089 100644 --- a/posthog/tracing/_ids.py +++ b/posthog/tracing/_ids.py @@ -1,10 +1,10 @@ """W3C Trace Context identifiers: 16-byte trace ids and 8-byte span ids, lowercase hex. The ingestion service zeroes an id of the wrong length rather than rejecting -it, which silently orphans the span, so every id is validated before it ships. +it, which silently orphans the span. Ids are valid by construction: generated +here, or accepted from a header the parser matched in full. """ -import re import secrets TRACE_ID_BYTES = 16 @@ -16,8 +16,6 @@ INVALID_TRACE_ID = "0" * TRACE_ID_HEX INVALID_SPAN_ID = "0" * SPAN_ID_HEX -_HEX_RE = re.compile(r"^[0-9a-f]+$") - def _random_hex_id(byte_length: int) -> str: hex_id = secrets.token_hex(byte_length) @@ -32,20 +30,3 @@ def new_trace_id() -> str: def new_span_id() -> str: return _random_hex_id(SPAN_ID_BYTES) - - -def _is_valid_hex_id(value: object, length: int, invalid: str) -> bool: - return ( - isinstance(value, str) - and len(value) == length - and value != invalid - and _HEX_RE.fullmatch(value) is not None - ) - - -def is_valid_trace_id(value: object) -> bool: - return _is_valid_hex_id(value, TRACE_ID_HEX, INVALID_TRACE_ID) - - -def is_valid_span_id(value: object) -> bool: - return _is_valid_hex_id(value, SPAN_ID_HEX, INVALID_SPAN_ID) diff --git a/posthog/tracing/_traceparent.py b/posthog/tracing/_traceparent.py index 10420d569..a30aa9ddb 100644 --- a/posthog/tracing/_traceparent.py +++ b/posthog/tracing/_traceparent.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import List, Optional -from ._ids import is_valid_span_id, is_valid_trace_id +from ._ids import INVALID_SPAN_ID, INVALID_TRACE_ID TRACE_FLAGS_SAMPLED = "01" TRACE_FLAGS_UNSAMPLED = "00" @@ -14,14 +14,14 @@ r"^([0-9a-f]{2})-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})(-.*)?$" ) -# W3C sets no bound; the tracestate cap keeps a peer's header from being +# W3C bounds neither header; the caps keep a peer's header from being # amplified onto every outbound call. _TRACEPARENT_MAX_LENGTH = 512 _TRACESTATE_MAX_MEMBERS = 32 _TRACESTATE_MAX_LENGTH = 512 # W3C: when trimming, drop members longer than this first. _TRACESTATE_LARGE_MEMBER_LENGTH = 128 -_TRACESTATE_FORBIDDEN_RE = re.compile(r"[^\x20-\x7e\t]") +_NON_PRINTABLE_ASCII_RE = re.compile(r"[^\x20-\x7e\t]") @dataclass(frozen=True) @@ -40,8 +40,19 @@ class _TraceparentFields: flags: str +def _header_text(value: object) -> Optional[str]: + """A header value as text; a raw ASGI scope carries it as ASCII bytes.""" + if isinstance(value, bytes): + try: + return value.decode("ascii") + except UnicodeDecodeError: + return None + return value if isinstance(value, str) else None + + def _match_traceparent(value: object) -> Optional[_TraceparentFields]: - if not isinstance(value, str): + value = _header_text(value) + if value is None: return None # Not lowercased: a conformant peer restarts the trace on uppercase hex. stripped = value.strip() @@ -53,9 +64,9 @@ def _match_traceparent(value: object) -> Optional[_TraceparentFields]: version, trace_id, span_id, flags, trailing = match.group(1, 2, 3, 4, 5) if version == "ff" or (version == "00" and trailing): return None - if trailing and _TRACESTATE_FORBIDDEN_RE.search(trailing): + if trailing and _NON_PRINTABLE_ASCII_RE.search(trailing): return None - if not is_valid_trace_id(trace_id) or not is_valid_span_id(span_id): + if trace_id == INVALID_TRACE_ID or span_id == INVALID_SPAN_ID: return None return _TraceparentFields(version, trace_id, span_id, flags) @@ -82,9 +93,10 @@ def normalize_traceparent(value: object) -> Optional[str]: Echoed whole rather than rebuilt, so a higher version keeps the fields this SDK does not read. """ - if not isinstance(value, str) or _match_traceparent(value) is None: + text = _header_text(value) + if text is None or _match_traceparent(text) is None: return None - return value.strip() + return text.strip() def traceparent_header(value: object) -> object: @@ -109,10 +121,11 @@ def sanitize_tracestate(value: object) -> Optional[str]: An invalid value is discarded without invalidating its traceparent. One over 512 characters is trimmed by whole members. """ - if not isinstance(value, str): + text = _header_text(value) + if text is None: return None - trimmed = value.strip() - if not trimmed or _TRACESTATE_FORBIDDEN_RE.search(trimmed): + trimmed = text.strip() + if not trimmed or _NON_PRINTABLE_ASCII_RE.search(trimmed): return None members = trimmed.split(",") if len(members) > _TRACESTATE_MAX_MEMBERS: From d3316397de7956f872d1dc12e086d6f2256a0474 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 16 Sep 2026 23:36:27 -0400 Subject: [PATCH 4/4] fix(traces): decode a bytes traceparent before the pipeline's type check The parser accepted bytes but the pipeline gates an explicit parent on str first, so a raw ASGI header value started a new trace. The header helper now decodes it, and leaves undecodable bytes for the parser to reject. --- posthog/test/tracing/test_traceparent.py | 8 ++++++++ posthog/tracing/_traceparent.py | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/posthog/test/tracing/test_traceparent.py b/posthog/test/tracing/test_traceparent.py index 3c6b06d43..19926bfec 100644 --- a/posthog/test/tracing/test_traceparent.py +++ b/posthog/test/tracing/test_traceparent.py @@ -142,6 +142,14 @@ def test_leaves_two_values_for_the_parser_to_reject(self): def test_passes_a_string_through(self): assert traceparent_header("x") == "x" + def test_decodes_a_raw_asgi_header_value_even_inside_a_list(self): + assert traceparent_header(b"x") == "x" + assert traceparent_header([b"x"]) == "x" + + def test_leaves_undecodable_bytes_for_the_parser_to_reject(self): + assert traceparent_header(b"caf\xc3\xa9") == b"caf\xc3\xa9" + assert parse_traceparent(traceparent_header(b"caf\xc3\xa9")) is None + class TestSanitizeTracestate: def test_preserves_a_valid_vendor_list_unchanged(self): diff --git a/posthog/tracing/_traceparent.py b/posthog/tracing/_traceparent.py index a30aa9ddb..974527b74 100644 --- a/posthog/tracing/_traceparent.py +++ b/posthog/tracing/_traceparent.py @@ -100,12 +100,16 @@ def normalize_traceparent(value: object) -> Optional[str]: def traceparent_header(value: object) -> object: - """Unwrap the one-element list a multi-value header API returns. + """Unwrap the one-element list a multi-value header API returns, and decode + the bytes a raw ASGI scope carries. - A longer list holds two different inbound values and is left to be rejected. + A longer list holds two different inbound values and is left to be rejected, + as is a value that is not ASCII. """ if isinstance(value, (list, tuple)) and len(value) == 1: - return value[0] + value = value[0] + if isinstance(value, bytes): + return _header_text(value) or value return value