From e9c3dc0ad06809584b48989b81b351114dd3350d Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Fri, 21 Aug 2026 16:05:49 +0200 Subject: [PATCH 1/3] refactor(ai): share processor capture policy --- posthog/ai/claude_agent_sdk/processor.py | 85 +++++--------- posthog/ai/openai_agents/processor.py | 49 +++----- posthog/ai/utils.py | 31 ++++- .../ai/claude_agent_sdk/test_processor.py | 111 +++++++++++++++++- .../test/ai/openai_agents/test_processor.py | 54 +++++++++ 5 files changed, 238 insertions(+), 92 deletions(-) diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index e3db83582..299c32ed7 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -30,7 +30,11 @@ format_tool_result_content, ) from posthog.ai.media import ensure_serializable as _ensure_serializable -from posthog.ai.utils import _capture_ai_event, finalize_ai_content +from posthog.ai.utils import ( + _capture_processor_event, + finalize_ai_content, + with_privacy_mode, +) from posthog.client import Client log = logging.getLogger("posthog") @@ -182,13 +186,6 @@ def _get_distinct_id( return str(self._distinct_id) return None - def _with_privacy_mode(self, value: Any) -> Any: - if self._privacy_mode or ( - hasattr(self._client, "privacy_mode") and self._client.privacy_mode - ): - return None - return value - def _capture_event( self, event: str, @@ -196,26 +193,14 @@ def _capture_event( distinct_id: Optional[str] = None, groups: Optional[Dict[str, Any]] = None, ) -> None: - try: - if not hasattr(self._client, "capture") or not callable( - self._client.capture - ): - return - - final_properties = { - **properties, - **self._properties, - } - - _capture_ai_event( - self._client, - event, - distinct_id=distinct_id or "unknown", - properties=final_properties, - groups=groups if groups is not None else self._groups, - ) - except Exception as e: - log.debug(f"Failed to capture PostHog event: {e}") + _capture_processor_event( + self._client, + event, + properties, + default_properties=self._properties, + distinct_id=distinct_id, + groups=groups if groups is not None else self._groups, + ) async def query( self, @@ -434,20 +419,16 @@ def _emit_generation( } if input_messages is not None: - properties["$ai_input"] = ( - None - if privacy - else self._with_privacy_mode( - finalize_ai_content(input_messages, self._client) - ) + properties["$ai_input"] = with_privacy_mode( + self._client, + privacy, + finalize_ai_content(input_messages, self._client), ) if output_choices is not None: - properties["$ai_output_choices"] = ( - None - if privacy - else self._with_privacy_mode( - finalize_ai_content(output_choices, self._client) - ) + properties["$ai_output_choices"] = with_privacy_mode( + self._client, + privacy, + finalize_ai_content(output_choices, self._client), ) if gen.cache_read_input_tokens: @@ -503,20 +484,16 @@ def _emit_generation_from_result( } if input_messages is not None: - properties["$ai_input"] = ( - None - if privacy - else self._with_privacy_mode( - finalize_ai_content(input_messages, self._client) - ) + properties["$ai_input"] = with_privacy_mode( + self._client, + privacy, + finalize_ai_content(input_messages, self._client), ) if output_choices is not None: - properties["$ai_output_choices"] = ( - None - if privacy - else self._with_privacy_mode( - finalize_ai_content(output_choices, self._client) - ) + properties["$ai_output_choices"] = with_privacy_mode( + self._client, + privacy, + finalize_ai_content(output_choices, self._client), ) cache_read = usage.get("cache_read_input_tokens", 0) @@ -561,9 +538,7 @@ def _emit_tool_span( **extra_props, } - if not privacy and not ( - hasattr(self._client, "privacy_mode") and self._client.privacy_mode - ): + if with_privacy_mode(self._client, privacy, True): properties["$ai_input_state"] = finalize_ai_content( _ensure_serializable(block.input), self._client ) diff --git a/posthog/ai/openai_agents/processor.py b/posthog/ai/openai_agents/processor.py index b075c02fa..e455addbe 100644 --- a/posthog/ai/openai_agents/processor.py +++ b/posthog/ai/openai_agents/processor.py @@ -22,7 +22,11 @@ from posthog import setup from posthog.ai.media import ensure_serializable as _ensure_serializable from posthog.ai.sanitization import _full_ai_capture_enabled, _placeholder -from posthog.ai.utils import _capture_ai_event, finalize_ai_content +from posthog.ai.utils import ( + _capture_processor_event, + finalize_ai_content, + with_privacy_mode, +) from posthog.client import Client log = logging.getLogger("posthog") @@ -119,11 +123,7 @@ def _get_distinct_id(self, trace: Optional[Trace]) -> Optional[str]: def _with_privacy_mode(self, value: Any) -> Any: """Apply privacy mode redaction if enabled.""" - if self._privacy_mode or ( - hasattr(self._client, "privacy_mode") and self._client.privacy_mode - ): - return None - return value + return with_privacy_mode(self._client, self._privacy_mode, value) def _evict_stale_entries(self) -> None: """Evict oldest entries if dicts exceed max size to prevent unbounded growth.""" @@ -159,34 +159,15 @@ def _capture_event( properties: Dict[str, Any], distinct_id: Optional[str] = None, ) -> None: - """Capture an event to PostHog with error handling. - - Args: - distinct_id: The resolved distinct ID. When the user didn't provide - one, callers should pass ``user_distinct_id or fallback_id`` - (matching the langchain/openai pattern) and separately set - ``$process_person_profile`` in properties. - """ - try: - if not hasattr(self._client, "capture") or not callable( - self._client.capture - ): - return - - final_properties = { - **properties, - **self._properties, - } - - _capture_ai_event( - self._client, - event, - distinct_id=distinct_id or "unknown", - properties=final_properties, - groups=self._groups, - ) - except Exception as e: - log.debug(f"Failed to capture PostHog event: {e}") + """Capture an event without allowing telemetry failures to escape.""" + _capture_processor_event( + self._client, + event, + properties, + default_properties=self._properties, + distinct_id=distinct_id, + groups=self._groups, + ) def on_trace_start(self, trace: Trace) -> None: """Called when a new trace begins. Stores metadata for spans; the $ai_trace event is emitted in on_trace_end.""" diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index 5436e2d07..deb47f765 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -1,3 +1,4 @@ +import logging import time import uuid from typing import Any, Callable, Dict, List, Optional, Tuple, cast @@ -70,6 +71,32 @@ def _capture_ai_event(ph_client, event: str, **kwargs): return ph_client.capture(event=event, **kwargs) +def _capture_processor_event( + ph_client: Any, + event: str, + properties: Dict[str, Any], + *, + default_properties: Optional[Dict[str, Any]] = None, + distinct_id: Optional[str] = None, + groups: Optional[Dict[str, Any]] = None, +) -> None: + """Apply the shared capture policy used by AI SDK processors.""" + try: + capture = getattr(ph_client, "capture", None) + if not callable(capture): + return + + _capture_ai_event( + ph_client, + event, + distinct_id=distinct_id or "unknown", + properties={**properties, **(default_properties or {})}, + groups=groups, + ) + except Exception as exc: + logging.getLogger("posthog").debug("Failed to capture PostHog event: %s", exc) + + def serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]]: """ Convert raw provider usage objects to JSON-serializable dicts. @@ -708,8 +735,8 @@ def finalize_ai_content(value: Any, ph_client: Any = None) -> Any: return redact_media(value, ph_client=ph_client) -def with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any): - if ph_client.privacy_mode or privacy_mode: +def with_privacy_mode(ph_client: Any, privacy_mode: bool, value: Any): + if getattr(ph_client, "privacy_mode", False) or privacy_mode: return None return value diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 97402a74b..51fd93675 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -2,6 +2,7 @@ import base64 import logging +from types import SimpleNamespace from typing import Any, Dict, List, Optional from unittest.mock import MagicMock, patch @@ -457,6 +458,67 @@ async def test_trace_emits_error_status(self, processor, mock_client): class TestPrivacyMode: + @pytest.mark.asyncio + async def test_client_privacy_mode_redacts_generation_input(self, mock_client): + mock_client.privacy_mode = True + proc = PostHogClaudeAgentProcessor(client=mock_client, distinct_id="user") + messages = [_make_message_start(), _make_message_stop()] + + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): + async for _ in proc.query(prompt="secret", options=ClaudeAgentOptions()): + pass + + properties = mock_client.capture.call_args.kwargs["properties"] + assert properties["$ai_input"] is None + + @pytest.mark.asyncio + @pytest.mark.parametrize( + ("processor_privacy", "call_privacy", "expected_input"), + [(False, True, None), (True, False, [{"role": "user", "content": "secret"}])], + ) + async def test_per_call_privacy_mode_overrides_processor_mode( + self, mock_client, processor_privacy, call_privacy, expected_input + ): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + distinct_id="user", + privacy_mode=processor_privacy, + ) + messages = [_make_message_start(), _make_message_stop()] + + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): + async for _ in proc.query( + prompt="secret", + options=ClaudeAgentOptions(), + posthog_privacy_mode=call_privacy, + ): + pass + + properties = mock_client.capture.call_args.kwargs["properties"] + assert properties["$ai_input"] == expected_input + + @pytest.mark.asyncio + async def test_client_without_privacy_mode_captures_content(self): + client = SimpleNamespace(capture=MagicMock()) + proc = PostHogClaudeAgentProcessor(client=client, distinct_id="user") + messages = [_make_message_start(), _make_message_stop()] + + with patch( + "posthog.ai.claude_agent_sdk.processor.original_query", + side_effect=lambda **kw: _fake_query(messages), + ): + async for _ in proc.query(prompt="visible", options=ClaudeAgentOptions()): + pass + + properties = client.capture.call_args.kwargs["properties"] + assert properties["$ai_input"] == [{"role": "user", "content": "visible"}] + @pytest.mark.asyncio async def test_privacy_mode_redacts_tool_input(self, mock_client): proc = PostHogClaudeAgentProcessor( @@ -596,12 +658,59 @@ async def test_no_distinct_id_sets_process_person_profile_false(self, mock_clien "posthog.ai.claude_agent_sdk.processor.original_query", side_effect=lambda **kw: _fake_query(messages), ): - async for _ in proc.query(prompt="Hi", options=ClaudeAgentOptions()): + async for _ in proc.query( + prompt="Hi", + options=ClaudeAgentOptions(), + posthog_trace_id="trace-fallback", + ): pass for call in mock_client.capture.call_args_list: props = call.kwargs.get("properties") or call[1].get("properties") assert props.get("$process_person_profile") is False + assert call.kwargs["distinct_id"] == "trace-fallback" + + +class TestCapturePolicy: + def test_explicit_groups_override_preserves_empty_groups(self, mock_client): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + groups={"company": "default"}, + ) + + proc._capture_event("$ai_trace", {}, groups={}) + + assert mock_client.capture.call_args.kwargs["groups"] == {} + + def test_default_properties_keep_existing_precedence(self, mock_client): + proc = PostHogClaudeAgentProcessor( + client=mock_client, + properties={"environment": "processor"}, + ) + + proc._capture_event( + "$ai_trace", + {"environment": "event", "$ai_trace_id": "trace-id"}, + ) + + assert mock_client.capture.call_args.kwargs["properties"] == { + "environment": "processor", + "$ai_trace_id": "trace-id", + } + + def test_client_without_capture_capability_is_ignored(self): + proc = PostHogClaudeAgentProcessor(client=object()) + + proc._capture_event("$ai_trace", {}) + + def test_capture_errors_are_logged_and_suppressed(self, mock_client, caplog): + mock_client.capture.side_effect = RuntimeError("capture failed") + proc = PostHogClaudeAgentProcessor(client=mock_client) + + with caplog.at_level(logging.DEBUG, logger="posthog"): + proc._capture_event("$ai_trace", {}) + + assert "Failed to capture PostHog event: capture failed" in caplog.text class TestCustomProperties: diff --git a/posthog/test/ai/openai_agents/test_processor.py b/posthog/test/ai/openai_agents/test_processor.py index ebff6237a..1ae63f9f0 100644 --- a/posthog/test/ai/openai_agents/test_processor.py +++ b/posthog/test/ai/openai_agents/test_processor.py @@ -1,5 +1,6 @@ import base64 import logging +from types import SimpleNamespace from unittest.mock import MagicMock, patch import pytest @@ -422,6 +423,38 @@ def test_privacy_mode_redacts_content(self, mock_client, mock_span): assert call_kwargs["properties"]["$ai_input_tokens"] == 10 assert call_kwargs["properties"]["$ai_output_tokens"] == 20 + def test_client_privacy_mode_redacts_content(self, mock_client, mock_span): + mock_client.privacy_mode = True + processor = PostHogTracingProcessor(client=mock_client) + mock_span.span_data = GenerationSpanData( + input=[{"role": "user", "content": "Secret message"}], + output=[{"role": "assistant", "content": "Secret response"}], + model="gpt-4o", + ) + + processor.on_span_start(mock_span) + processor.on_span_end(mock_span) + + properties = mock_client.capture.call_args.kwargs["properties"] + assert properties["$ai_input"] is None + assert properties["$ai_output_choices"] is None + + def test_client_without_privacy_mode_captures_content(self, mock_span): + client = SimpleNamespace(capture=MagicMock()) + processor = PostHogTracingProcessor(client=client) + mock_span.span_data = GenerationSpanData( + input=[{"role": "user", "content": "Visible message"}], + model="gpt-4o", + ) + + processor.on_span_start(mock_span) + processor.on_span_end(mock_span) + + properties = client.capture.call_args.kwargs["properties"] + assert properties["$ai_input"] == [ + {"role": "user", "content": "Visible message"} + ] + def test_generation_span_image_input_is_redacted( self, processor, mock_client, mock_span ): @@ -1000,6 +1033,27 @@ def test_instrument_with_groups_and_properties(self, mock_client): assert processor._properties == {"env": "test"} +class TestCapturePolicy: + def test_processor_groups_are_forwarded(self, mock_client): + processor = PostHogTracingProcessor( + client=mock_client, + groups={"company": "acme"}, + ) + + processor._capture_event("$ai_trace", {}) + + assert mock_client.capture.call_args.kwargs["groups"] == {"company": "acme"} + + def test_capture_errors_are_logged_and_suppressed(self, mock_client, caplog): + mock_client.capture.side_effect = RuntimeError("capture failed") + processor = PostHogTracingProcessor(client=mock_client) + + with caplog.at_level(logging.DEBUG, logger="posthog"): + processor._capture_event("$ai_trace", {}) + + assert "Failed to capture PostHog event: capture failed" in caplog.text + + def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_trace): mock_client.enable_full_ai_capture = True processor = PostHogTracingProcessor(client=mock_client, distinct_id="test-user") From fb9410e84bfb34d16ecb9ea9c7031fb3a4980472 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Sat, 22 Aug 2026 08:53:55 +0200 Subject: [PATCH 2/3] fix(ai): preserve processor public API --- posthog/ai/claude_agent_sdk/processor.py | 12 ++++++------ posthog/ai/openai_agents/processor.py | 4 ++-- posthog/ai/utils.py | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index 299c32ed7..cad2e103f 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -33,7 +33,7 @@ from posthog.ai.utils import ( _capture_processor_event, finalize_ai_content, - with_privacy_mode, + with_privacy_mode as _with_privacy_mode, ) from posthog.client import Client @@ -419,13 +419,13 @@ def _emit_generation( } if input_messages is not None: - properties["$ai_input"] = with_privacy_mode( + properties["$ai_input"] = _with_privacy_mode( self._client, privacy, finalize_ai_content(input_messages, self._client), ) if output_choices is not None: - properties["$ai_output_choices"] = with_privacy_mode( + properties["$ai_output_choices"] = _with_privacy_mode( self._client, privacy, finalize_ai_content(output_choices, self._client), @@ -484,13 +484,13 @@ def _emit_generation_from_result( } if input_messages is not None: - properties["$ai_input"] = with_privacy_mode( + properties["$ai_input"] = _with_privacy_mode( self._client, privacy, finalize_ai_content(input_messages, self._client), ) if output_choices is not None: - properties["$ai_output_choices"] = with_privacy_mode( + properties["$ai_output_choices"] = _with_privacy_mode( self._client, privacy, finalize_ai_content(output_choices, self._client), @@ -538,7 +538,7 @@ def _emit_tool_span( **extra_props, } - if with_privacy_mode(self._client, privacy, True): + if _with_privacy_mode(self._client, privacy, True): properties["$ai_input_state"] = finalize_ai_content( _ensure_serializable(block.input), self._client ) diff --git a/posthog/ai/openai_agents/processor.py b/posthog/ai/openai_agents/processor.py index e455addbe..d5418089f 100644 --- a/posthog/ai/openai_agents/processor.py +++ b/posthog/ai/openai_agents/processor.py @@ -25,7 +25,7 @@ from posthog.ai.utils import ( _capture_processor_event, finalize_ai_content, - with_privacy_mode, + with_privacy_mode as _with_privacy_mode, ) from posthog.client import Client @@ -123,7 +123,7 @@ def _get_distinct_id(self, trace: Optional[Trace]) -> Optional[str]: def _with_privacy_mode(self, value: Any) -> Any: """Apply privacy mode redaction if enabled.""" - return with_privacy_mode(self._client, self._privacy_mode, value) + return _with_privacy_mode(self._client, self._privacy_mode, value) def _evict_stale_entries(self) -> None: """Evict oldest entries if dicts exceed max size to prevent unbounded growth.""" diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index deb47f765..776539aa1 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -735,7 +735,7 @@ def finalize_ai_content(value: Any, ph_client: Any = None) -> Any: return redact_media(value, ph_client=ph_client) -def with_privacy_mode(ph_client: Any, privacy_mode: bool, value: Any): +def with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any): if getattr(ph_client, "privacy_mode", False) or privacy_mode: return None return value From 3e12bc41ee9f9d8ae1b94ba5b7f4efa344e3165f Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 25 Aug 2026 09:26:41 +0200 Subject: [PATCH 3/3] fix(ai): preserve processor privacy precedence --- posthog/ai/claude_agent_sdk/processor.py | 8 +++----- posthog/test/ai/claude_agent_sdk/test_processor.py | 10 +++++----- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index cad2e103f..cc53726e4 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -237,11 +237,9 @@ async def query( distinct_id_override = posthog_distinct_id or self._distinct_id trace_id = posthog_trace_id or str(uuid.uuid4()) extra_props = posthog_properties or {} - privacy = ( - posthog_privacy_mode - if posthog_privacy_mode is not None - else self._privacy_mode - ) + # Per-call privacy can enable redaction, but cannot disable the + # processor-level setting. This preserves the existing precedence. + privacy = self._privacy_mode or posthog_privacy_mode is True groups = posthog_groups or self._groups # Ensure partial messages are enabled for per-generation tracking diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 51fd93675..57cbd3c54 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -476,11 +476,11 @@ async def test_client_privacy_mode_redacts_generation_input(self, mock_client): @pytest.mark.asyncio @pytest.mark.parametrize( - ("processor_privacy", "call_privacy", "expected_input"), - [(False, True, None), (True, False, [{"role": "user", "content": "secret"}])], + ("processor_privacy", "call_privacy"), + [(False, True), (True, False)], ) - async def test_per_call_privacy_mode_overrides_processor_mode( - self, mock_client, processor_privacy, call_privacy, expected_input + async def test_per_call_privacy_mode_cannot_disable_processor_mode( + self, mock_client, processor_privacy, call_privacy ): proc = PostHogClaudeAgentProcessor( client=mock_client, @@ -501,7 +501,7 @@ async def test_per_call_privacy_mode_overrides_processor_mode( pass properties = mock_client.capture.call_args.kwargs["properties"] - assert properties["$ai_input"] == expected_input + assert properties["$ai_input"] is None @pytest.mark.asyncio async def test_client_without_privacy_mode_captures_content(self):