From a9ef7baa324d85d77f73d3fa66bb2a7bb6a506a4 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Mon, 24 Aug 2026 14:28:27 -0700 Subject: [PATCH 1/4] feat: opt-in OTel trace/span IDs on regular capture() via capture_trace_context Attach the active OpenTelemetry span's trace_id/span_id as `$trace_id`/`$span_id` properties on events captured with `capture()`/`capture_ai()`, behind an opt-in `capture_trace_context` client option. Reuses the existing `_get_current_otel_span_properties()` helper already used by `capture_exception()`. Explicit `$trace_id`/`$span_id` in properties win; default is off, so no behavior change for existing clients. Generated-By: PostHog Desktop Task-Id: 44c7be3e-4938-4f9a-bb80-89fd9d74db6d --- posthog/__init__.py | 8 ++++ posthog/client.py | 12 +++++ posthog/test/test_client.py | 75 ++++++++++++++++++++++++++++++ references/public_api_snapshot.txt | 10 ++-- 4 files changed, 101 insertions(+), 4 deletions(-) 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..d9db7689 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -689,6 +689,7 @@ def __init__( enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, + capture_trace_context=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, @@ -778,6 +779,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 +933,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 +1599,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..f3889982 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -625,6 +625,81 @@ 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( + [ + ( + "active_context", + 0x123, + 0x456, + {}, + "00000000000000000000000000000123", + "0000000000000456", + ), + ( + "explicit_properties_win", + 0x123, + 0x456, + {"$trace_id": "custom-trace", "$span_id": "custom-span"}, + "custom-trace", + "custom-span", + ), + ("invalid_context", 0, 0, {}, None, None), + ] + ) + def test_capture_uses_current_otel_span_context_when_enabled( + 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, capture_trace_context=True + ) + client.capture( + "test_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) + + def test_capture_does_not_attach_otel_span_context_by_default(self): + 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) + client.capture("test_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..959214ba 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -374,8 +374,8 @@ attribute posthog.ai.claude_agent_sdk.client.log = logging.getLogger('posthog') attribute posthog.ai.claude_agent_sdk.processor.log = logging.getLogger('posthog') attribute posthog.ai.gateway.POSTHOG_AI_GATEWAY_HOSTS = ['gateway.posthog.com', 'gateway.us.posthog.com', 'gateway.eu.posthog.com', 'ai-gateway.us.posthog.com', 'ai-gateway.eu.posthog.com'] attribute posthog.ai.gateway.log = logging.getLogger('posthog') -attribute posthog.ai.gemini.gemini.Client.models = Models(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=(self._ph_client), posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) -attribute posthog.ai.gemini.gemini_async.AsyncClient.models = AsyncModels(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=(self._ph_client), posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) +attribute posthog.ai.gemini.gemini.Client.models = Models(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=self._ph_client, posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) +attribute posthog.ai.gemini.gemini_async.AsyncClient.models = AsyncModels(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=self._ph_client, posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) attribute posthog.ai.gemini.gemini_converter.GeminiMessage.content: Union[str, List[Any]] attribute posthog.ai.gemini.gemini_converter.GeminiMessage.parts: List[Union[GeminiPart, Dict[str, Any]]] attribute posthog.ai.gemini.gemini_converter.GeminiMessage.role: str @@ -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 @@ -622,7 +624,7 @@ attribute posthog.contexts.ContextScope.fresh = fresh attribute posthog.contexts.ContextScope.parent = parent attribute posthog.contexts.ContextScope.session_id: Optional[str] = None attribute posthog.contexts.ContextScope.tags: Dict[str, Any] = {} -attribute posthog.contexts.F = TypeVar('F', bound=(Callable[..., Any])) +attribute posthog.contexts.F = TypeVar('F', bound=Callable[..., Any]) attribute posthog.debug = False attribute posthog.default_client = None attribute posthog.disable_geoip = True @@ -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, capture_trace_context=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.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) From 174e57a631c86c38cbd44b6a224a006401e33923 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Mon, 24 Aug 2026 15:33:04 -0700 Subject: [PATCH 2/4] chore: regenerate public API snapshot under Python 3.11 (CI env) The initial snapshot was generated under Python 3.12, but CI runs the check under 3.11.11 and griffe's AST unparsing differs across versions (self._ph_client -> (self._ph_client), Callable[..., Any] -> (Callable[..., Any])). Regenerated in a pinned 3.11.11 uv env matching CI (uv sync --extra dev), so `make public_api_check` now passes there. No code changes. Generated-By: PostHog Desktop Task-Id: 44c7be3e-4938-4f9a-bb80-89fd9d74db6d --- references/public_api_snapshot.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 959214ba..13f8a251 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -374,8 +374,8 @@ attribute posthog.ai.claude_agent_sdk.client.log = logging.getLogger('posthog') attribute posthog.ai.claude_agent_sdk.processor.log = logging.getLogger('posthog') attribute posthog.ai.gateway.POSTHOG_AI_GATEWAY_HOSTS = ['gateway.posthog.com', 'gateway.us.posthog.com', 'gateway.eu.posthog.com', 'ai-gateway.us.posthog.com', 'ai-gateway.eu.posthog.com'] attribute posthog.ai.gateway.log = logging.getLogger('posthog') -attribute posthog.ai.gemini.gemini.Client.models = Models(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=self._ph_client, posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) -attribute posthog.ai.gemini.gemini_async.AsyncClient.models = AsyncModels(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=self._ph_client, posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) +attribute posthog.ai.gemini.gemini.Client.models = Models(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=(self._ph_client), posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) +attribute posthog.ai.gemini.gemini_async.AsyncClient.models = AsyncModels(api_key=api_key, vertexai=vertexai, credentials=credentials, project=project, location=location, debug_config=debug_config, http_options=http_options, posthog_client=(self._ph_client), posthog_distinct_id=posthog_distinct_id, posthog_properties=posthog_properties, posthog_privacy_mode=posthog_privacy_mode, posthog_groups=posthog_groups, **kwargs) attribute posthog.ai.gemini.gemini_converter.GeminiMessage.content: Union[str, List[Any]] attribute posthog.ai.gemini.gemini_converter.GeminiMessage.parts: List[Union[GeminiPart, Dict[str, Any]]] attribute posthog.ai.gemini.gemini_converter.GeminiMessage.role: str @@ -624,7 +624,7 @@ attribute posthog.contexts.ContextScope.fresh = fresh attribute posthog.contexts.ContextScope.parent = parent attribute posthog.contexts.ContextScope.session_id: Optional[str] = None attribute posthog.contexts.ContextScope.tags: Dict[str, Any] = {} -attribute posthog.contexts.F = TypeVar('F', bound=Callable[..., Any]) +attribute posthog.contexts.F = TypeVar('F', bound=(Callable[..., Any])) attribute posthog.debug = False attribute posthog.default_client = None attribute posthog.disable_geoip = True From 2448feec0635b7610fed1327294e016a7c056e21 Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Tue, 25 Aug 2026 07:10:54 -0700 Subject: [PATCH 3/4] fix: append capture_trace_context so existing positional args keep their slots Inserting the option next to capture_exception_code_variables shifted every later Client/Posthog parameter by one position, so a caller passing code_variables_mask_patterns (or anything after it) positionally would silently land on the wrong setting. Move it to the end of the public parameters, matching how secret_key and metrics were appended rather than grouped with their related options. Snapshot regenerated under Python 3.11.11 to match CI. Generated-By: PostHog Desktop Task-Id: 44c7be3e-4938-4f9a-bb80-89fd9d74db6d --- posthog/client.py | 4 +++- references/public_api_snapshot.txt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/posthog/client.py b/posthog/client.py index d9db7689..57f7b2d2 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -689,7 +689,6 @@ def __init__( enable_local_evaluation=True, flag_definition_cache_provider: Optional[FlagDefinitionCacheProvider] = None, capture_exception_code_variables=False, - capture_trace_context=False, code_variables_mask_patterns=None, code_variables_ignore_patterns=None, code_variables_mask_url_credentials=None, @@ -704,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, ): diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 13f8a251..13aa4821 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -922,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, capture_trace_context=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) From 693878181b8312e77da76e2b97492e6d26602afa Mon Sep 17 00:00:00 2001 From: Daniel Visca Date: Tue, 25 Aug 2026 07:11:03 -0700 Subject: [PATCH 4/4] test: cover capture_ai in the OTel trace-context tests The option is documented as applying to both capture() and capture_ai(), but the tests only exercised capture(). Parameterize the entrypoint so a future divergence of capture_ai from the shared _capture path breaks the documented trace-correlation behavior instead of passing silently. Verified by mutation: gating the injection on the analytics lane fails capture_ai_active_context. Generated-By: PostHog Desktop Task-Id: 44c7be3e-4938-4f9a-bb80-89fd9d74db6d --- posthog/test/test_client.py | 39 +++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index f3889982..087a79ca 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -628,7 +628,8 @@ def test_capture_exception_uses_current_otel_span_context( @parameterized.expand( [ ( - "active_context", + "capture_active_context", + "capture", 0x123, 0x456, {}, @@ -636,19 +637,40 @@ def test_capture_exception_uses_current_otel_span_context( "0000000000000456", ), ( - "explicit_properties_win", + "capture_explicit_properties_win", + "capture", 0x123, 0x456, {"$trace_id": "custom-trace", "$span_id": "custom-span"}, "custom-trace", "custom-span", ), - ("invalid_context", 0, 0, {}, None, None), + ("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, @@ -669,9 +691,8 @@ def test_capture_uses_current_otel_span_context_when_enabled( client = Client( FAKE_TEST_API_KEY, sync_mode=True, capture_trace_context=True ) - client.capture( - "test_event", distinct_id="distinct_id", properties=properties - ) + 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: @@ -681,7 +702,8 @@ def test_capture_uses_current_otel_span_context_when_enabled( self.assertEqual(event["properties"]["$trace_id"], expected_trace_id) self.assertEqual(event["properties"]["$span_id"], expected_span_id) - def test_capture_does_not_attach_otel_span_context_by_default(self): + @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, @@ -694,7 +716,8 @@ def test_capture_does_not_attach_otel_span_context_by_default(self): use_span(NonRecordingSpan(span_context)), ): client = Client(FAKE_TEST_API_KEY, sync_mode=True) - client.capture("test_event", distinct_id="distinct_id") + 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"])