From 05a06d2bab9045bbc1b579aa6d1f5d6d8697650b Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 16 Jul 2026 12:02:44 +0200 Subject: [PATCH 1/2] feat(error-tracking): link exceptions to active spans --- .../capture-exception-otel-context.md | 5 ++ posthog/client.py | 5 ++ posthog/exception_utils.py | 18 ++++++ posthog/test/test_client.py | 55 ++++++++++++++++++- 4 files changed, 82 insertions(+), 1 deletion(-) create mode 100644 .sampo/changesets/capture-exception-otel-context.md diff --git a/.sampo/changesets/capture-exception-otel-context.md b/.sampo/changesets/capture-exception-otel-context.md new file mode 100644 index 00000000..eb0dbe37 --- /dev/null +++ b/.sampo/changesets/capture-exception-otel-context.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Add the active OpenTelemetry span's `$trace_id` and `$span_id` to events captured with `capture_exception`. diff --git a/posthog/client.py b/posthog/client.py index e87a4787..73dcd5a7 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -52,6 +52,7 @@ exc_info_from_error, exception_is_already_captured, exceptions_from_error_tuple, + get_current_otel_span_properties, handle_in_app, mark_exception_as_captured, try_attach_code_variables_to_frames, @@ -1405,6 +1406,9 @@ def capture_exception( """ Capture an exception for error tracking. + 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. + Args: exception: The exception to capture. distinct_id: The distinct ID of the user. @@ -1467,6 +1471,7 @@ def capture_exception( properties = { "$exception_list": all_exceptions_with_trace_and_in_app, + **get_current_otel_span_properties(), **properties, } diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index ab7775a4..d04d48a2 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -98,6 +98,24 @@ ) +def get_current_otel_span_properties() -> Dict[str, str]: + try: + from opentelemetry import trace + except ImportError: + return {} + + try: + span_context = trace.get_current_span().get_span_context() + if not span_context.is_valid: + return {} + return { + "$trace_id": f"{span_context.trace_id:032x}", + "$span_id": f"{span_context.span_id:016x}", + } + except Exception: + return {} + + def _redact_url_credentials(value): if "://" not in value: return value diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index 0cdf2ece..9a218263 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -4,9 +4,10 @@ import unittest import warnings from datetime import datetime +from unittest import mock from uuid import UUID, uuid4 -from unittest import mock +from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags, use_span from parameterized import parameterized from posthog.capture_compression import CaptureCompression @@ -487,6 +488,58 @@ def test_basic_capture_exception(self): self.assertEqual(capture_call[0][0], "$exception") self.assertEqual(capture_call[1]["distinct_id"], "distinct_id") + @parameterized.expand( + [ + ( + "active_context", + 0x123, + 0x456, + {}, + "00000000000000000000000000000123", + "0000000000000456", + ), + ( + "explicit_properties", + 0x123, + 0x456, + {"$trace_id": "custom-trace", "$span_id": "custom-span"}, + "custom-trace", + "custom-span", + ), + ("invalid_context", 0, 0, {}, None, None), + ] + ) + def test_capture_exception_uses_current_otel_span_context( + self, + _, + context_trace_id, + context_span_id, + properties, + expected_trace_id, + expected_span_id, + ): + span_context = SpanContext( + trace_id=context_trace_id, + span_id=context_span_id, + is_remote=False, + trace_flags=TraceFlags.SAMPLED, + ) + + with ( + mock.patch("posthog.client.batch_post") as mock_post, + use_span(NonRecordingSpan(span_context)), + ): + client = Client(FAKE_TEST_API_KEY, sync_mode=True) + client.capture_exception(Exception("test exception"), properties=properties) + + event = mock_post.call_args.kwargs["batch"][0] + if expected_trace_id is None: + self.assertNotIn("$trace_id", event["properties"]) + self.assertNotIn("$span_id", event["properties"]) + else: + self.assertEqual(event["properties"]["$trace_id"], expected_trace_id) + self.assertEqual(event["properties"]["$span_id"], expected_span_id) + def test_basic_capture_exception_with_distinct_id(self): with mock.patch.object(Client, "capture", return_value=None) as patch_capture: client = self.client From b90e11b96aa1912932d6315eb424cf1523e1503e Mon Sep 17 00:00:00 2001 From: Hugues Pouillot Date: Thu, 16 Jul 2026 12:03:42 +0200 Subject: [PATCH 2/2] fix(error-tracking): keep telemetry helper internal --- posthog/client.py | 4 ++-- posthog/exception_utils.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index 73dcd5a7..64baf087 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -52,7 +52,7 @@ exc_info_from_error, exception_is_already_captured, exceptions_from_error_tuple, - get_current_otel_span_properties, + _get_current_otel_span_properties, handle_in_app, mark_exception_as_captured, try_attach_code_variables_to_frames, @@ -1471,7 +1471,7 @@ def capture_exception( properties = { "$exception_list": all_exceptions_with_trace_and_in_app, - **get_current_otel_span_properties(), + **_get_current_otel_span_properties(), **properties, } diff --git a/posthog/exception_utils.py b/posthog/exception_utils.py index d04d48a2..9d1b8ffa 100644 --- a/posthog/exception_utils.py +++ b/posthog/exception_utils.py @@ -98,7 +98,7 @@ ) -def get_current_otel_span_properties() -> Dict[str, str]: +def _get_current_otel_span_properties() -> Dict[str, str]: try: from opentelemetry import trace except ImportError: