From 04617915e9b852b3d9dc8cad44668560583a6518 Mon Sep 17 00:00:00 2001 From: Eli Reisman Date: Fri, 23 Oct 2026 17:36:53 -0700 Subject: [PATCH] fix: add opentelemetry span ids to async capture_exception AsyncPosthog.capture_exception now adds the active span's $trace_id and $span_id even with capture_trace_context off, like Client.capture_exception. They follow the same SDK-value fill order and pair rule. Co-Authored-By: Claude Opus 5.5 --- .sampo/changesets/async-exception-span-ids.md | 5 +++++ docs/migration-7.x-to-8.0.md | 1 + posthog/async_client.py | 6 ++++++ posthog/test/test_event_options.py | 15 +++++++++------ 4 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 .sampo/changesets/async-exception-span-ids.md diff --git a/.sampo/changesets/async-exception-span-ids.md b/.sampo/changesets/async-exception-span-ids.md new file mode 100644 index 00000000..b4dd157b --- /dev/null +++ b/.sampo/changesets/async-exception-span-ids.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +`AsyncPosthog.capture_exception` now adds the active OpenTelemetry span's `$trace_id` and `$span_id`, like `Client.capture_exception`, even when `capture_trace_context` is off. diff --git a/docs/migration-7.x-to-8.0.md b/docs/migration-7.x-to-8.0.md index da9aacc4..9f99a700 100644 --- a/docs/migration-7.x-to-8.0.md +++ b/docs/migration-7.x-to-8.0.md @@ -159,6 +159,7 @@ The headers only fill a missing distinct ID or session ID, and any client can se - `capture_exception` takes a `level=` argument on `Client`, `AsyncPosthog` and the module. It sets `$exception_level`, for example `"warning"` or `"fatal"`. The default is `"error"`, and an unknown value counts as unset. - `capture_exception` ignores reserved exception properties in `properties`, such as `$exception_list`, `$exception_level` and `$exception_source`. In 7.x they overrode the SDK's values, with a `DeprecationWarning`. Use `level=` instead of `$exception_level`. - `AsyncPosthog.capture_exception` now sends `$exception_level`. +- `AsyncPosthog.capture_exception` now adds `$trace_id` and `$span_id` from the active OpenTelemetry span, like `Client.capture_exception`. In 7.x it added them only with `capture_trace_context=True`. ## Event options diff --git a/posthog/async_client.py b/posthog/async_client.py index 5a7f849f..8adb494a 100644 --- a/posthog/async_client.py +++ b/posthog/async_client.py @@ -610,6 +610,7 @@ def _build_capture_event( trace_properties=( _get_current_otel_span_properties() if self.capture_trace_context + or kwargs.get("_capture_trace_context") else None ), ), @@ -922,6 +923,10 @@ def capture_exception( ``level`` sets ``$exception_level`` and defaults to ``"error"``. An unknown value counts as unset. Reserved exception properties in ``properties``, such as ``$exception_list`` and ``$exception_level``, are ignored. + + When OpenTelemetry is installed and a valid span is active, its trace and + span IDs are added as ``$trace_id`` and ``$span_id`` event properties, + unless the event, a context tag or ``super_properties`` sets either one. """ try: if exception is not None and exception_is_already_captured(exception): @@ -998,6 +1003,7 @@ def capture_exception( flags=kwargs.get("flags"), disable_geoip=kwargs.get("disable_geoip"), options=kwargs.get("options"), + _capture_trace_context=True, ) if exception is not None and result is not None: mark_exception_as_captured(exception, result) diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index 34e4b9d4..abc0fa29 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -531,8 +531,8 @@ def _span_ids(properties): return {k: properties[k] for k in SPAN_IDS if k in properties} -def _sync_trace_context(method): - # Sync capture_exception adds the span ids even with capture_trace_context off. +def _trace_context_setting(method): + # capture_exception adds the span ids even with capture_trace_context off. return method != "capture_exception" @@ -541,7 +541,7 @@ def _sync_trace_context(method): def test_sync_span_ids_fill_as_a_pair_below_caller_layers(method, source, expected): events = _sync_wire_events( _capture_in_span(method, source), - **_trace_config(source, _sync_trace_context(method)), + **_trace_config(source, _trace_context_setting(method)), ) assert _span_ids(events[0]["properties"]) == expected @@ -553,7 +553,8 @@ async def test_async_span_ids_fill_as_a_pair_below_caller_layers( method, source, expected ): events = await _async_wire_events( - _capture_in_span(method, source), **_trace_config(source) + _capture_in_span(method, source), + **_trace_config(source, _trace_context_setting(method)), ) assert _span_ids(events[0]["properties"]) == expected @@ -572,7 +573,7 @@ def test_sync_before_send_sees_and_changes_span_ids(method): events = _sync_wire_events( _capture_in_span(method), before_send=_span_id_hook(seen), - **_trace_config(None, _sync_trace_context(method)), + **_trace_config(None, _trace_context_setting(method)), ) assert seen == [SPAN_IDS] assert _span_ids(events[0]["properties"]) == {**SPAN_IDS, "$span_id": "hook"} @@ -583,7 +584,9 @@ def test_sync_before_send_sees_and_changes_span_ids(method): async def test_async_before_send_sees_and_changes_span_ids(method): seen: list = [] events = await _async_wire_events( - _capture_in_span(method), before_send=_span_id_hook(seen), **_trace_config(None) + _capture_in_span(method), + before_send=_span_id_hook(seen), + **_trace_config(None, _trace_context_setting(method)), ) assert seen == [SPAN_IDS] assert _span_ids(events[0]["properties"]) == {**SPAN_IDS, "$span_id": "hook"}