diff --git a/posthog/__init__.py b/posthog/__init__.py index b423bd45..f18a318e 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -349,6 +349,12 @@ def get_tags() -> Dict[str, Any]: feature flag definitions across workers. capture_exception_code_variables: Capture local variable values on exception stack frames. + capture_trace_context: When OpenTelemetry is installed and a valid span is + active at capture time, add its trace and span IDs as ``$trace_id`` and + ``$span_id`` properties to events captured with ``capture()`` and + ``capture_ai()``. Explicit ``$trace_id``/``$span_id`` values passed in + ``properties`` win. Exception events always attach these IDs regardless + of this setting. Defaults to False. code_variables_mask_patterns: Variable-name patterns to mask when capturing code variables. code_variables_ignore_patterns: Variable-name patterns to omit when capturing @@ -410,6 +416,7 @@ def get_tags() -> Dict[str, Any]: default_client = None # type: Optional[Client] +capture_trace_context = False capture_exception_code_variables = False code_variables_mask_patterns = DEFAULT_CODE_VARIABLES_MASK_PATTERNS code_variables_ignore_patterns = DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS @@ -1260,6 +1267,7 @@ def setup() -> Client: enable_local_evaluation=enable_local_evaluation, flag_definition_cache_provider=flag_definition_cache_provider, capture_exception_code_variables=capture_exception_code_variables, + capture_trace_context=capture_trace_context, code_variables_mask_patterns=code_variables_mask_patterns, code_variables_ignore_patterns=code_variables_ignore_patterns, code_variables_mask_url_credentials=code_variables_mask_url_credentials, diff --git a/posthog/client.py b/posthog/client.py index 2ddfd1fd..57f7b2d2 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -703,6 +703,9 @@ def __init__( secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, + # Appended rather than grouped with the other `capture_*` options so + # existing positional arguments keep their slots. + capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False, ): @@ -778,6 +781,13 @@ def __init__( sharing feature flag definitions across workers. capture_exception_code_variables: Capture local variable values on exception stack frames. + capture_trace_context: When OpenTelemetry is installed and a valid span is + active at capture time, add its trace and span IDs as ``$trace_id`` and + ``$span_id`` properties to events captured with ``capture()`` and + ``capture_ai()``, so they can be correlated with backend traces. Explicit + ``$trace_id``/``$span_id`` values passed in ``properties`` win. Exception + events (``capture_exception``) always attach these IDs regardless of this + setting. Defaults to False. code_variables_mask_patterns: Variable-name patterns to mask when capturing code variables. code_variables_ignore_patterns: Variable-name patterns to omit when @@ -925,6 +935,7 @@ def __init__( # server reports it, so full events are the fail-safe. self._minimal_flag_called_events: bool = False + self.capture_trace_context = capture_trace_context self.capture_exception_code_variables = capture_exception_code_variables self.code_variables_mask_patterns = ( code_variables_mask_patterns @@ -1590,6 +1601,9 @@ def _capture( properties = {**(properties or {}), **system_context()} + if self.capture_trace_context: + properties = {**_get_current_otel_span_properties(), **properties} + properties = add_context_tags(properties) assert properties is not None # Type hint for mypy diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index c44b699f..087a79ca 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -625,6 +625,104 @@ def test_capture_exception_uses_current_otel_span_context( self.assertEqual(event["properties"]["$trace_id"], expected_trace_id) self.assertEqual(event["properties"]["$span_id"], expected_span_id) + @parameterized.expand( + [ + ( + "capture_active_context", + "capture", + 0x123, + 0x456, + {}, + "00000000000000000000000000000123", + "0000000000000456", + ), + ( + "capture_explicit_properties_win", + "capture", + 0x123, + 0x456, + {"$trace_id": "custom-trace", "$span_id": "custom-span"}, + "custom-trace", + "custom-span", + ), + ("capture_invalid_context", "capture", 0, 0, {}, None, None), + ( + "capture_ai_active_context", + "capture_ai", + 0x123, + 0x456, + {}, + "00000000000000000000000000000123", + "0000000000000456", + ), + ( + "capture_ai_explicit_properties_win", + "capture_ai", + 0x123, + 0x456, + {"$trace_id": "custom-trace", "$span_id": "custom-span"}, + "custom-trace", + "custom-span", + ), + ("capture_ai_invalid_context", "capture_ai", 0, 0, {}, None, None), + ] + ) + def test_capture_uses_current_otel_span_context_when_enabled( + self, + _, + entrypoint, + 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, capture_trace_context=True + ) + capture = getattr(client, entrypoint) + capture("$ai_event", distinct_id="distinct_id", 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) + + @parameterized.expand([("capture",), ("capture_ai",)]) + def test_capture_does_not_attach_otel_span_context_by_default(self, entrypoint): + span_context = SpanContext( + trace_id=0x123, + span_id=0x456, + 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) + capture = getattr(client, entrypoint) + capture("$ai_event", distinct_id="distinct_id") + + event = mock_post.call_args.kwargs["batch"][0] + self.assertNotIn("$trace_id", event["properties"]) + self.assertNotIn("$span_id", event["properties"]) + def test_basic_capture_exception_with_distinct_id(self): with mock.patch.object(Client, "capture", return_value=None) as patch_capture: client = self.client diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 54ca98d0..13aa4821 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -528,6 +528,7 @@ attribute posthog.capture_exception_code_variables = False attribute posthog.capture_mode.CAPTURE_MODE_ENV_VAR = 'POSTHOG_CAPTURE_MODE' attribute posthog.capture_mode.CaptureMode.V0 = 'v0' attribute posthog.capture_mode.CaptureMode.V1 = 'v1' +attribute posthog.capture_trace_context = False attribute posthog.capture_v1.CaptureV1Error.attempts = attempts attribute posthog.capture_v1.CaptureV1Error.drops = drops or [] attribute posthog.capture_v1.CaptureV1Error.request_id = request_id @@ -536,6 +537,7 @@ attribute posthog.client.Client.api_key = (project_api_key or '').strip() attribute posthog.client.Client.capture_compression = _resolve_capture_compression(capture_compression, gzip_fallback=gzip) attribute posthog.client.Client.capture_exception_code_variables = capture_exception_code_variables attribute posthog.client.Client.capture_mode = _resolve_capture_mode(capture_mode) +attribute posthog.client.Client.capture_trace_context = capture_trace_context attribute posthog.client.Client.code_variables_detect_secrets = code_variables_detect_secrets if code_variables_detect_secrets is not None else DEFAULT_CODE_VARIABLES_DETECT_SECRETS attribute posthog.client.Client.code_variables_ignore_patterns = code_variables_ignore_patterns if code_variables_ignore_patterns is not None else DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS attribute posthog.client.Client.code_variables_mask_patterns = code_variables_mask_patterns if code_variables_mask_patterns is not None else DEFAULT_CODE_VARIABLES_MASK_PATTERNS @@ -920,7 +922,7 @@ class posthog.bucketed_rate_limiter.BucketedRateLimiter(bucket_size: Number, ref class posthog.capture_compression.CaptureCompression class posthog.capture_mode.CaptureMode class posthog.capture_v1.CaptureV1Error(status: int | str, message: str, *, retry_after: Optional[float] = None, request_id: Optional[str] = None, attempts: Optional[int] = None, retry_exhausted: Optional[list[str]] = None, drops: Optional[list[tuple[str, Optional[str]]]] = None) -class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, _use_ai_lane=False, _enable_multimodal_capture=False) +class posthog.client.Client(project_api_key: str, host=None, debug=False, max_queue_size=10000, send=True, on_error=None, flush_at=100, flush_interval=5.0, gzip=False, max_retries=3, sync_mode=False, timeout=15, thread=1, poll_interval=30, personal_api_key=None, disabled=False, disable_geoip=True, is_server=True, historical_migration=False, feature_flags_request_timeout_seconds=3, feature_flags_request_max_retries=1, super_properties=None, enable_exception_autocapture=False, log_captured_exceptions=False, project_root=None, privacy_mode=False, before_send=None, flag_fallback_cache_url=None, enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, code_variables_detect_secrets=None, in_app_modules: list[str] | None = None, enable_exception_autocapture_rate_limiting=False, exception_autocapture_bucket_size=ExceptionCapture.DEFAULT_BUCKET_SIZE, exception_autocapture_refill_rate=ExceptionCapture.DEFAULT_REFILL_RATE, exception_autocapture_refill_interval_seconds=ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS, capture_mode: Optional[Union[CaptureMode, str]] = None, capture_compression: Optional[Union[CaptureCompression, str]] = None, secret_key=None, metrics: Optional[dict] = None, enable_full_ai_capture=False, capture_trace_context=False, _use_ai_lane=False, _enable_multimodal_capture=False) class posthog.consumer.Consumer(queue, api_key, flush_at=100, host=None, on_error=None, flush_interval=5.0, gzip=False, retries=10, timeout=15, historical_migration=False, endpoint=EVENTS_ENDPOINT, max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE) class posthog.contexts.ContextScope(parent=None, fresh: bool = False, capture_exceptions: bool = True, client: Optional[Client] = None) class posthog.exception_capture.ExceptionCapture(client: Client, rate_limiting_enabled=False, bucket_size=DEFAULT_BUCKET_SIZE, refill_rate=DEFAULT_REFILL_RATE, refill_interval_seconds=DEFAULT_REFILL_INTERVAL_SECONDS)