Skip to content

Commit 06fbd90

Browse files
eli-r-phclaude
andauthored
fix: fill OpenTelemetry trace IDs below context tags and super properties (#1122)
fix: fill opentelemetry trace ids below context tags and super properties The $trace_id and $span_id taken from the active OpenTelemetry span are values the SDK adds, so they now fill after the call's properties, context tags and super_properties, and before before_send. They fill as a pair: when any layer sets either key, the SDK adds neither. This covers capture, capture_ai and capture_exception on Client and AsyncPosthog. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent cfdb4ec commit 06fbd90

8 files changed

Lines changed: 165 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: major
3+
---
4+
5+
The `$trace_id` and `$span_id` that the SDK takes from the active OpenTelemetry span now fill after the call's `properties`, context tags and `super_properties`, and before `before_send`. A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the span's IDs, and setting either key stops the SDK adding both. `AsyncPosthog` follows the same rules.

‎docs/migration-7.x-to-8.0.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ Values apply in this order. Steps 2 to 4 fill only the options and properties th
201201
1. the `options` and `properties` of the call, then the call's `disable_geoip` argument, which fills `$geoip_disable`
202202
2. context options and tags
203203
3. `super_options` and `super_properties`
204-
4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/<key>` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID`
204+
4. values the SDK sets: `$is_server` from `is_server`, `$geoip_disable` from the client's `disable_geoip` setting, system properties such as `$os` and `$python_version`, the `$feature/<key>` and `$active_feature_flags` properties from `flags` or `send_feature_flags`, `$trace_id` and `$span_id` from the active OpenTelemetry span, `options.process_person_profile = false` for events without a distinct ID, and `$release_id` from `POSTHOG_RELEASE_ID`
205205
5. `before_send`, which sees the result of steps 1 to 4 and can change or remove any of it
206206
6. legacy properties, which fill unset options and are then removed
207207

@@ -215,6 +215,7 @@ These changes follow from this order:
215215
- Properties passed to a call now override `super_properties`. `super_properties` can no longer change `$lib` or `$lib_version`.
216216
- A `$is_server`, `$geoip_disable` or system property such as `$os` that you set in a call or a context tag now wins over the SDK's value. In 7.x the SDK overwrote it. `super_properties` win over the SDK's value too, so `super_properties={"$geoip_disable": False}` turns GeoIP lookup on for events, even with `disable_geoip=True`.
217217
- Feature flag properties from `flags` or `send_feature_flags` still lose to the same key in the call's `properties`, a context tag or `super_properties`, as in 7.x.
218+
- A `$trace_id` or `$span_id` in a context tag or `super_properties` now replaces the active OpenTelemetry span's IDs. In 7.x the span's IDs beat context tags. Setting either key in the call's `properties`, a context tag or `super_properties` stops the SDK adding both. In 7.x the SDK still added the other key.
218219
- A `disable_geoip` argument to a call belongs to that event, so it wins over context tags and `super_properties`. A `$geoip_disable` in the call's own `properties` still wins over it. `disable_geoip=False` now sends `$geoip_disable: false`. In 7.x it sent no `$geoip_disable` property.
219220
- The `groups` argument merges into a `$groups` property of the call, and wins key by key. In 7.x it replaced the property. MCP events merge their identity's groups into a custom `$groups` property the same way.
220221
- A `$set`, `$set_once`, `$groups` or `$group_set` in `super_properties` no longer replaces the whole value of the call. The two merge, and the call wins key by key.

‎posthog/__init__.py‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -432,9 +432,11 @@ def get_context_options() -> Dict[str, Any]:
432432
capture_trace_context: When OpenTelemetry is installed and a valid span is
433433
active at capture time, add its trace and span IDs as ``$trace_id`` and
434434
``$span_id`` properties to events captured with ``capture()`` and
435-
``capture_ai()``. Explicit ``$trace_id``/``$span_id`` values passed in
436-
``properties`` win. Exception events always attach these IDs regardless
437-
of this setting. Defaults to False.
435+
``capture_ai()``. They fill after context tags and ``super_properties``,
436+
before ``before_send``. A ``$trace_id`` or ``$span_id`` from the event, a
437+
context tag or ``super_properties`` stops the SDK adding either ID.
438+
Exception events always attach these IDs regardless of this setting.
439+
Defaults to False.
438440
code_variables_mask_patterns: Variable-name patterns to mask when capturing
439441
code variables.
440442
code_variables_ignore_patterns: Variable-name patterns to omit when capturing

‎posthog/args.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ class OptionalCaptureArgs(TypedDict):
6969
_property_allowlist: NotRequired[
7070
Optional[FrozenSet[str]]
7171
] # Internal: strict allowlist applied to the fully-enriched event properties. Used by minimal $feature_flag_called events.
72+
_capture_trace_context: NotRequired[
73+
bool
74+
] # Internal: add the active OpenTelemetry span's ids even when capture_trace_context is off. Used by capture_exception.
7275

7376

7477
class OptionalSetArgs(TypedDict):

‎posthog/async_client.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -551,6 +551,7 @@ def _event_defaults(
551551
disable_geoip: Optional[bool] = None,
552552
system_properties: Optional[dict[str, Any]] = None,
553553
flag_properties: Optional[dict[str, Any]] = None,
554+
trace_properties: Optional[dict[str, Any]] = None,
554555
) -> _EventDefaults:
555556
return _build_event_defaults(
556557
super_properties=self.super_properties,
@@ -565,14 +566,13 @@ def _event_defaults(
565566
call_disable_geoip=disable_geoip,
566567
system_properties=system_properties,
567568
flag_properties=flag_properties,
569+
trace_properties=trace_properties,
568570
)
569571

570572
def _build_capture_event(
571573
self, event: str, kwargs: OptionalCaptureArgs
572574
) -> tuple[dict[str, Any], Any, _EventDefaults]:
573575
properties = dict(kwargs.get("properties") or {})
574-
if self.capture_trace_context:
575-
properties = {**_get_current_otel_span_properties(), **properties}
576576
properties = _add_context_session_id(properties)
577577

578578
distinct_id, personless = _get_identity_state(kwargs.get("distinct_id"))
@@ -611,6 +611,11 @@ def _build_capture_event(
611611
disable_geoip=kwargs.get("disable_geoip"),
612612
system_properties=system_context(),
613613
flag_properties=flag_properties,
614+
trace_properties=(
615+
_get_current_otel_span_properties()
616+
if self.capture_trace_context
617+
else None
618+
),
614619
),
615620
)
616621

‎posthog/capture_event.py‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ class _EventDefaults:
133133
property_layers: tuple[Mapping[str, Any], ...] = ()
134134
option_layers: tuple[Mapping[str, Any], ...] = ()
135135
property_allowlist: Optional[Collection[str]] = None
136+
trace_properties: Optional[Mapping[str, Any]] = None
136137

137138

138139
def _build_event_defaults(
@@ -149,6 +150,7 @@ def _build_event_defaults(
149150
call_disable_geoip: Optional[bool] = None,
150151
system_properties: Optional[Mapping[str, Any]] = None,
151152
flag_properties: Optional[Mapping[str, Any]] = None,
153+
trace_properties: Optional[Mapping[str, Any]] = None,
152154
) -> _EventDefaults:
153155
"""Order the layers: per-call arguments, context, global, then SDK values.
154156
@@ -157,7 +159,8 @@ def _build_event_defaults(
157159
event's own ``$geoip_disable`` property beats it. ``flag_properties`` (the
158160
``$feature/*`` and ``$active_feature_flags`` values from ``flags`` or
159161
``send_feature_flags``) are SDK values, so context and super properties
160-
beat them.
162+
beat them. ``trace_properties`` (``$trace_id`` and ``$span_id`` from the
163+
active OpenTelemetry span) are SDK values that fill as a pair.
161164
"""
162165
call_properties = (
163166
{} if call_disable_geoip is None else {"$geoip_disable": call_disable_geoip}
@@ -184,6 +187,7 @@ def _build_event_defaults(
184187
derived_options or {},
185188
),
186189
property_allowlist=property_allowlist,
190+
trace_properties=trace_properties,
187191
)
188192

189193

@@ -195,7 +199,8 @@ def _fill_event_defaults(
195199
A property is unset only when its key is missing. An option is unset when
196200
it is missing or ``None``, the same rule hoisting uses. ``$set``,
197201
``$set_once``, ``$groups`` and ``$group_set`` fill one level deep when the
198-
event's value and the default are both dicts.
202+
event's value and the default are both dicts. ``trace_properties`` fill
203+
last, and only when the event and every layer leave all their keys unset.
199204
"""
200205
if defaults is None:
201206
return
@@ -218,6 +223,13 @@ def _fill_event_defaults(
218223
and isinstance(value, Mapping)
219224
):
220225
properties[key] = {**_clean(dict(value)), **existing}
226+
# A trace id and a span id from different spans would point at no real span,
227+
# so a value for either key from any layer keeps out both of the SDK's ids.
228+
trace_properties = defaults.trace_properties or {}
229+
if not any(key in properties for key in trace_properties):
230+
for key, value in trace_properties.items():
231+
if allowlist is None or key in allowlist:
232+
properties[key] = _clean(value)
221233
options = _event_options(msg.get("options"))
222234
for option_layer in defaults.option_layers:
223235
for key, value in option_layer.items():

‎posthog/client.py‎

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -884,8 +884,10 @@ def __init__(
884884
capture_trace_context: When OpenTelemetry is installed and a valid span is
885885
active at capture time, add its trace and span IDs as ``$trace_id`` and
886886
``$span_id`` properties to events captured with ``capture()`` and
887-
``capture_ai()``, so they can be correlated with backend traces. Explicit
888-
``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception
887+
``capture_ai()``, so they can be correlated with backend traces. They fill
888+
after context tags and ``super_properties``, before ``before_send``. A
889+
``$trace_id`` or ``$span_id`` from the event, a context tag or
890+
``super_properties`` stops the SDK adding either ID. Exception
889891
events (``capture_exception``) always attach these IDs regardless of this
890892
setting. Defaults to False.
891893
traces: Config dict for distributed tracing: ``service_name``,
@@ -1808,8 +1810,11 @@ def _capture(
18081810

18091811
properties = dict(properties or {})
18101812

1811-
if self.capture_trace_context:
1812-
properties = {**_get_current_otel_span_properties(), **properties}
1813+
trace_properties = (
1814+
_get_current_otel_span_properties()
1815+
if self.capture_trace_context or kwargs.get("_capture_trace_context")
1816+
else None
1817+
)
18131818

18141819
properties = _add_context_session_id(properties)
18151820

@@ -1924,6 +1929,7 @@ def _capture(
19241929
derived_options=_personless_options(personless),
19251930
system_properties=system_context(),
19261931
flag_properties=extra_properties,
1932+
trace_properties=trace_properties,
19271933
)
19281934

19291935
def _parse_send_feature_flags(self, send_feature_flags) -> SendFeatureFlagsOptions:
@@ -2239,7 +2245,8 @@ def capture_exception(
22392245
Capture an exception for error tracking.
22402246
22412247
When OpenTelemetry is installed and a valid span is active, its trace and
2242-
span IDs are added as ``$trace_id`` and ``$span_id`` event properties.
2248+
span IDs are added as ``$trace_id`` and ``$span_id`` event properties,
2249+
unless the event, a context tag or ``super_properties`` sets either one.
22432250
22442251
Args:
22452252
exception: The exception to capture.
@@ -2316,7 +2323,6 @@ def capture_exception(
23162323

23172324
caller_properties = properties
23182325
properties = {
2319-
**_get_current_otel_span_properties(),
23202326
"$exception_list": all_exceptions_with_trace_and_in_app,
23212327
"$exception_level": _exception_level(
23222328
capture_metadata.get("level"), level
@@ -2388,6 +2394,7 @@ def capture_exception(
23882394
send_feature_flags=send_feature_flags,
23892395
disable_geoip=disable_geoip,
23902396
options=kwargs.get("options", None),
2397+
_capture_trace_context=True,
23912398
)
23922399

23932400
# Mark the exception as captured to prevent duplicate captures
@@ -2539,6 +2546,7 @@ def _enqueue(
25392546
derived_options=None,
25402547
system_properties=None,
25412548
flag_properties=None,
2549+
trace_properties=None,
25422550
):
25432551
# type: (...) -> Optional[str]
25442552
"""Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None."""
@@ -2587,6 +2595,7 @@ def _enqueue(
25872595
call_disable_geoip=disable_geoip,
25882596
system_properties=system_properties,
25892597
flag_properties=flag_properties,
2598+
trace_properties=trace_properties,
25902599
),
25912600
)
25922601

‎posthog/test/test_event_options.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33

44
import pytest
5+
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags, use_span
56

67
from posthog import AsyncPosthog, new_context, set_context_option, tag
78
from posthog.ai.utils import _capture_ai_event
@@ -475,6 +476,119 @@ async def test_async_caller_layers_beat_flag_properties(source):
475476
_assert_flag_layers(events, source)
476477

477478

479+
SPAN_IDS = {
480+
"$trace_id": "00000000000000000000000000000123",
481+
"$span_id": "0000000000000456",
482+
}
483+
484+
# Each caller layer sets one key of the pair, so the SDK adds neither span id.
485+
TRACE_ID_CASES = [
486+
pytest.param(None, SPAN_IDS, id="no_layer"),
487+
pytest.param("event", {"$trace_id": "caller"}, id="event_trace_id"),
488+
pytest.param("context", {"$trace_id": "caller"}, id="context_trace_id"),
489+
pytest.param("super", {"$span_id": "caller"}, id="super_span_id"),
490+
]
491+
492+
TRACE_CALLS = {
493+
"capture": lambda c, p: c.capture("e", distinct_id="u", properties=p),
494+
"capture_ai": lambda c, p: c.capture_ai(
495+
"$ai_generation", distinct_id="u", properties=p
496+
),
497+
"capture_exception": lambda c, p: c.capture_exception(
498+
ValueError("boom"), distinct_id="u", properties=p
499+
),
500+
}
501+
502+
503+
def _capture_in_span(method, source=None):
504+
span = NonRecordingSpan(
505+
SpanContext(
506+
trace_id=0x123,
507+
span_id=0x456,
508+
is_remote=False,
509+
trace_flags=TraceFlags.SAMPLED,
510+
)
511+
)
512+
513+
def call(client):
514+
with new_context(fresh=True), use_span(span):
515+
if source == "context":
516+
tag("$trace_id", "caller")
517+
properties = {"$trace_id": "caller"} if source == "event" else None
518+
return TRACE_CALLS[method](client, properties)
519+
520+
return call
521+
522+
523+
def _trace_config(source, capture_trace_context=True):
524+
config: dict = {"capture_trace_context": capture_trace_context}
525+
if source == "super":
526+
config["super_properties"] = {"$span_id": "caller"}
527+
return config
528+
529+
530+
def _span_ids(properties):
531+
return {k: properties[k] for k in SPAN_IDS if k in properties}
532+
533+
534+
def _sync_trace_context(method):
535+
# Sync capture_exception adds the span ids even with capture_trace_context off.
536+
return method != "capture_exception"
537+
538+
539+
@pytest.mark.parametrize("method", list(TRACE_CALLS))
540+
@pytest.mark.parametrize("source,expected", TRACE_ID_CASES)
541+
def test_sync_span_ids_fill_as_a_pair_below_caller_layers(method, source, expected):
542+
events = _sync_wire_events(
543+
_capture_in_span(method, source),
544+
**_trace_config(source, _sync_trace_context(method)),
545+
)
546+
assert _span_ids(events[0]["properties"]) == expected
547+
548+
549+
@pytest.mark.asyncio
550+
@pytest.mark.parametrize("method", list(TRACE_CALLS))
551+
@pytest.mark.parametrize("source,expected", TRACE_ID_CASES)
552+
async def test_async_span_ids_fill_as_a_pair_below_caller_layers(
553+
method, source, expected
554+
):
555+
events = await _async_wire_events(
556+
_capture_in_span(method, source), **_trace_config(source)
557+
)
558+
assert _span_ids(events[0]["properties"]) == expected
559+
560+
561+
def _span_id_hook(seen):
562+
def hook(msg):
563+
seen.append(_span_ids(msg["properties"]))
564+
return {**msg, "properties": {**msg["properties"], "$span_id": "hook"}}
565+
566+
return hook
567+
568+
569+
@pytest.mark.parametrize("method", list(TRACE_CALLS))
570+
def test_sync_before_send_sees_and_changes_span_ids(method):
571+
seen: list = []
572+
events = _sync_wire_events(
573+
_capture_in_span(method),
574+
before_send=_span_id_hook(seen),
575+
**_trace_config(None, _sync_trace_context(method)),
576+
)
577+
assert seen == [SPAN_IDS]
578+
assert _span_ids(events[0]["properties"]) == {**SPAN_IDS, "$span_id": "hook"}
579+
580+
581+
@pytest.mark.asyncio
582+
@pytest.mark.parametrize("method", list(TRACE_CALLS))
583+
async def test_async_before_send_sees_and_changes_span_ids(method):
584+
seen: list = []
585+
events = await _async_wire_events(
586+
_capture_in_span(method), before_send=_span_id_hook(seen), **_trace_config(None)
587+
)
588+
assert seen == [SPAN_IDS]
589+
assert _span_ids(events[0]["properties"]) == {**SPAN_IDS, "$span_id": "hook"}
590+
591+
478592
GEOIP_CALL_CASES = [
479593
pytest.param(True, "context", False, True, id="call_beats_context"),
480594
pytest.param(True, "super", False, True, id="call_beats_super"),

0 commit comments

Comments
 (0)