diff --git a/posthog/ai/claude_agent_sdk/client.py b/posthog/ai/claude_agent_sdk/client.py index f9a718d80..2766ec925 100644 --- a/posthog/ai/claude_agent_sdk/client.py +++ b/posthog/ai/claude_agent_sdk/client.py @@ -242,14 +242,12 @@ async def disconnect(self) -> None: **self._extra_props, } - if resolved_id is None: - properties["$process_person_profile"] = False - self._processor._capture_event( "$ai_trace", properties, resolved_id or self._trace_id, self._groups, + personless=resolved_id is None, ) try: diff --git a/posthog/ai/claude_agent_sdk/processor.py b/posthog/ai/claude_agent_sdk/processor.py index 1d45b61c2..15a830556 100644 --- a/posthog/ai/claude_agent_sdk/processor.py +++ b/posthog/ai/claude_agent_sdk/processor.py @@ -192,6 +192,7 @@ def _capture_event( properties: Dict[str, Any], distinct_id: Optional[str] = None, groups: Optional[Dict[str, Any]] = None, + personless: bool = False, ) -> None: _capture_processor_event( self._client, @@ -200,6 +201,7 @@ def _capture_event( default_properties=self._properties, distinct_id=distinct_id, groups=groups if groups is not None else self._groups, + personless=personless, ) async def query( @@ -449,11 +451,12 @@ def _emit_generation( if gen.stop_reason is not None: properties["$ai_stop_reason"] = gen.stop_reason - if resolved_id is None: - properties["$process_person_profile"] = False - self._capture_event( - "$ai_generation", properties, resolved_id or trace_id, groups + "$ai_generation", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, ) def _emit_generation_from_result( @@ -522,11 +525,12 @@ def _emit_generation_from_result( if result.total_cost_usd is not None: properties["$ai_total_cost_usd"] = result.total_cost_usd - if resolved_id is None: - properties["$process_person_profile"] = False - self._capture_event( - "$ai_generation", properties, resolved_id or trace_id, groups + "$ai_generation", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, ) def _emit_tool_span( @@ -557,10 +561,13 @@ def _emit_tool_span( _ensure_serializable(block.input), self._client ) - if resolved_id is None: - properties["$process_person_profile"] = False - - self._capture_event("$ai_span", properties, resolved_id or trace_id, groups) + self._capture_event( + "$ai_span", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, + ) def _emit_trace( self, @@ -592,10 +599,13 @@ def _emit_trace( if result.total_cost_usd is not None: properties["$ai_total_cost_usd"] = result.total_cost_usd - if resolved_id is None: - properties["$process_person_profile"] = False - - self._capture_event("$ai_trace", properties, resolved_id or trace_id, groups) + self._capture_event( + "$ai_trace", + properties, + resolved_id or trace_id, + groups, + personless=resolved_id is None, + ) # Flush to ensure events are sent before process exits try: diff --git a/posthog/ai/gemini/_shared.py b/posthog/ai/gemini/_shared.py index fc3635589..633c0ac86 100644 --- a/posthog/ai/gemini/_shared.py +++ b/posthog/ai/gemini/_shared.py @@ -266,13 +266,11 @@ def _capture_embedding_outcome( event_properties["$ai_is_error"] = True event_properties["$ai_error"] = str(error) - if distinct_id is None: - event_properties["$process_person_profile"] = False - _capture_ai_event( self._ph_client, "$ai_embedding", distinct_id=distinct_id or trace_id, properties=event_properties, groups=groups, + personless=distinct_id is None, ) diff --git a/posthog/ai/langchain/callbacks.py b/posthog/ai/langchain/callbacks.py index 3b9e188b0..a80cd1a1d 100644 --- a/posthog/ai/langchain/callbacks.py +++ b/posthog/ai/langchain/callbacks.py @@ -577,15 +577,13 @@ def _capture_trace_or_span( finalize_ai_content(outputs, self._ph_client), ) - if self._distinct_id is None: - event_properties["$process_person_profile"] = False - _capture_ai_event( self._ph_client, event_name, distinct_id=self._distinct_id or run_id, properties=event_properties, groups=self._groups, + personless=self._distinct_id is None, ) def _pop_run_and_capture_generation( @@ -662,9 +660,6 @@ def _capture_generation( if self._properties: event_properties.update(self._properties) - if self._distinct_id is None: - event_properties["$process_person_profile"] = False - if isinstance(output, BaseException): event_properties["$ai_http_status"] = _get_http_status(output) event_properties["$ai_error"] = _stringify_exception(output) @@ -737,6 +732,7 @@ def _capture_generation( distinct_id=self._distinct_id or trace_id, properties=event_properties, groups=self._groups, + personless=self._distinct_id is None, ) def _log_debug_event( diff --git a/posthog/ai/openai/_embeddings.py b/posthog/ai/openai/_embeddings.py index 8cdb04d33..49dc0f5b5 100644 --- a/posthog/ai/openai/_embeddings.py +++ b/posthog/ai/openai/_embeddings.py @@ -38,9 +38,6 @@ def _capture_embedding_event( **(properties or {}), } - if distinct_id is None: - event_properties["$process_person_profile"] = False - if hasattr(posthog_client, "capture"): _capture_ai_event( posthog_client, @@ -48,4 +45,5 @@ def _capture_embedding_event( distinct_id=distinct_id or trace_id, properties=event_properties, groups=groups, + personless=distinct_id is None, ) diff --git a/posthog/ai/openai_agents/processor.py b/posthog/ai/openai_agents/processor.py index 8bdea6c7e..e90432c2f 100644 --- a/posthog/ai/openai_agents/processor.py +++ b/posthog/ai/openai_agents/processor.py @@ -159,14 +159,18 @@ def _capture_event( properties: Dict[str, Any], distinct_id: Optional[str] = None, ) -> None: - """Capture an event without allowing telemetry failures to escape.""" + """Capture an event without allowing telemetry failures to escape. + + Without a distinct ID, the event is personless and uses its trace ID. + """ _capture_processor_event( self._client, event, properties, default_properties=self._properties, - distinct_id=distinct_id, + distinct_id=distinct_id or properties.get("$ai_trace_id"), groups=self._groups, + personless=distinct_id is None, ) def on_trace_start(self, trace: Trace) -> None: @@ -228,12 +232,9 @@ def on_trace_end(self, trace: Trace) -> None: if metadata: properties["$ai_trace_metadata"] = _ensure_serializable(metadata) - if distinct_id is None: - properties["$process_person_profile"] = False - self._capture_event( event="$ai_trace", - distinct_id=distinct_id or trace_id, + distinct_id=distinct_id, properties=properties, ) except Exception as e: @@ -267,8 +268,8 @@ def on_span_end(self, span: Span[Any]) -> None: latency = (ended - started) if (started and ended) else 0 # Get user-provided distinct ID from trace metadata (resolved at trace start). - # None means no user-provided ID — use trace_id as fallback in personless mode, - # matching the langchain/openai pattern: `distinct_id or trace_id`. + # None means no user-provided ID: _capture_event falls back to the + # trace ID and makes the event personless. trace_info = self._trace_metadata.get(trace_id, {}) distinct_id = trace_info.get("distinct_id") or self._get_distinct_id(None) @@ -317,11 +318,6 @@ def on_span_end(self, span: Span[Any]) -> None: "$ai_error_type": error_type, } - # Personless mode: no user-provided distinct_id, fallback to trace_id - if distinct_id is None: - error_properties["$process_person_profile"] = False - distinct_id = trace_id - # Dispatch based on span data type if isinstance(span_data, GenerationSpanData): self._handle_generation_span( @@ -471,7 +467,7 @@ def _handle_generation_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -554,7 +550,7 @@ def _handle_function_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -587,7 +583,7 @@ def _handle_agent_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -616,7 +612,7 @@ def _handle_handoff_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -640,7 +636,7 @@ def _handle_guardrail_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -663,7 +659,7 @@ def _handle_response_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -732,7 +728,7 @@ def _handle_custom_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -757,7 +753,7 @@ def _handle_audio_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -832,7 +828,7 @@ def _handle_mcp_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: @@ -856,7 +852,7 @@ def _handle_generic_span( span_id: str, parent_id: Optional[str], latency: float, - distinct_id: str, + distinct_id: Optional[str], group_id: Optional[str], error_properties: Dict[str, Any], ) -> None: diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index ffc2c864c..ae652e80a 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -69,12 +69,24 @@ def _ai_lane_enabled(ph_client) -> bool: return _full_ai_capture_enabled(ph_client) -def _capture_ai_event(ph_client, event: str, **kwargs): - """Capture a wrapper-emitted AI event with the PostHog AI library identity.""" +def _capture_ai_event(ph_client, event: str, *, personless: bool = False, **kwargs): + """Capture a wrapper-emitted AI event with the PostHog AI library identity. + + ``personless`` marks an event whose distinct ID is a fallback, such as a + trace ID, not a real user. + """ kwargs["properties"] = { **_AI_LIB_PROPERTIES, **(kwargs.get("properties") or {}), } + if personless: + # A per-event option beats legacy properties and global or context + # options, because a person profile for a fallback ID is always wrong. + # before_send can still change it. + kwargs["options"] = { + **(kwargs.get("options") or {}), + "process_person_profile": False, + } if _ai_lane_enabled(ph_client): capture_ai = getattr(ph_client, "capture_ai", None) if callable(capture_ai): @@ -90,6 +102,7 @@ def _capture_processor_event( default_properties: Optional[Dict[str, Any]] = None, distinct_id: Optional[str] = None, groups: Optional[Dict[str, Any]] = None, + personless: bool = False, ) -> None: """Apply the shared capture policy used by AI SDK processors.""" try: @@ -103,6 +116,7 @@ def _capture_processor_event( distinct_id=distinct_id or "unknown", properties={**properties, **(default_properties or {})}, groups=groups, + personless=personless, ) except Exception as exc: logging.getLogger("posthog").debug("Failed to capture PostHog event: %s", exc) @@ -586,9 +600,6 @@ def call_llm_and_track_usage( if stop_reason is not None: tag("$ai_stop_reason", stop_reason) - if not has_person_distinct_id: - tag("$process_person_profile", False) - # Process instructions for Responses API if provider == "openai" and kwargs.get("instructions") is not None: tag( @@ -615,6 +626,7 @@ def call_llm_and_track_usage( distinct_id=contexts.get_context_distinct_id(), properties=merged_properties, groups=posthog_groups, + personless=not has_person_distinct_id, ) if error: @@ -753,9 +765,6 @@ async def call_llm_and_track_usage_async( if stop_reason is not None: tag("$ai_stop_reason", stop_reason) - if not has_person_distinct_id: - tag("$process_person_profile", False) - # Process instructions for Responses API if provider == "openai" and kwargs.get("instructions") is not None: tag( @@ -782,6 +791,7 @@ async def call_llm_and_track_usage_async( distinct_id=contexts.get_context_distinct_id(), properties=merged_properties, groups=posthog_groups, + personless=not has_person_distinct_id, ) if error: @@ -964,9 +974,6 @@ def capture_streaming_event( event_data["kwargs"]["instructions"], ) - if event_data.get("distinct_id") is None: - event_properties["$process_person_profile"] = False - # Send event to PostHog if hasattr(ph_client, "capture"): _capture_ai_event( @@ -975,4 +982,5 @@ def capture_streaming_event( distinct_id=event_data.get("distinct_id") or trace_id, properties=event_properties, groups=event_data.get("groups"), + personless=event_data.get("distinct_id") is None, ) diff --git a/posthog/mcp/_posthog_events.py b/posthog/mcp/_posthog_events.py index 0ee8e31f0..a85fcebc7 100644 --- a/posthog/mcp/_posthog_events.py +++ b/posthog/mcp/_posthog_events.py @@ -64,7 +64,6 @@ def _build_capture_event(event: Event) -> PostHogCaptureEvent: properties: Dict[str, Any] = {_P.SOURCE: POSTHOG_MCP_ANALYTICS_SOURCE} _add_session_id(event, properties) _add_conversation_id(event, properties) - _add_person_processing(event, properties) _add_common_properties(event, properties) _add_custom_properties(event, properties) _add_groups(event, properties) @@ -77,6 +76,7 @@ def _build_capture_event(event: Event) -> PostHogCaptureEvent: "event": event_name, "distinct_id": _get_distinct_id(event), "properties": properties, + "options": _get_options(event), "timestamp": _get_timestamp(event), } @@ -100,11 +100,13 @@ def _add_groups(event: Event, properties: Dict[str, Any]) -> None: _merge_groups(properties, groups) -def _add_person_processing(event: Event, properties: Dict[str, Any]) -> None: +def _get_options(event: Event) -> Dict[str, Any]: # Without a resolved identity the distinct id is just the session id, so # processing a person profile would mint one anonymous person per session. + # As an option it beats any custom $process_person_profile property. if not event.get("identify_actor_given_id"): - properties["$process_person_profile"] = False + return {"process_person_profile": False} + return {} def _is_tool_call(event: Event) -> bool: @@ -266,7 +268,6 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent: properties: Dict[str, Any] = {} _add_session_id(event, properties) _add_conversation_id(event, properties) - _add_person_processing(event, properties) _add_groups(event, properties) error = event.get("error") @@ -301,5 +302,6 @@ def _build_exception_event(event: Event) -> PostHogCaptureEvent: "event": PostHogMCPAnalyticsEvent.EXCEPTION, "distinct_id": _get_distinct_id(event), "properties": properties, + "options": _get_options(event), "timestamp": _get_timestamp(event), } diff --git a/posthog/mcp/_sink.py b/posthog/mcp/_sink.py index 7d1db967f..15a5e789a 100644 --- a/posthog/mcp/_sink.py +++ b/posthog/mcp/_sink.py @@ -106,6 +106,7 @@ async def capture(self, event: McpEvent, options: McpCaptureOptions) -> None: capture_event["event"], distinct_id=capture_event["distinct_id"], properties=capture_event["properties"], + options=capture_event.get("options"), timestamp=capture_event.get("timestamp"), uuid=_uuid7(), ) diff --git a/posthog/mcp/types.py b/posthog/mcp/types.py index f65ae0d26..5b0efbc1a 100644 --- a/posthog/mcp/types.py +++ b/posthog/mcp/types.py @@ -78,6 +78,7 @@ class PostHogCaptureEvent(TypedDict, total=False): distinct_id: str event: str properties: Dict[str, Any] + options: Dict[str, Any] timestamp: datetime diff --git a/posthog/test/ai/anthropic/test_anthropic.py b/posthog/test/ai/anthropic/test_anthropic.py index 84275e9ad..a4a3e9bc6 100644 --- a/posthog/test/ai/anthropic/test_anthropic.py +++ b/posthog/test/ai/anthropic/test_anthropic.py @@ -1735,25 +1735,52 @@ async def run_test(): # ======================= -def test_no_distinct_id_uses_trace_id_and_personless( - mock_client, mock_anthropic_response +@pytest.mark.parametrize("stream", [False, True], ids=["response", "stream"]) +@pytest.mark.parametrize("is_async", [False, True], ids=["sync", "async"]) +async def test_no_distinct_id_uses_trace_id_and_personless( + mock_client, + mock_anthropic_response, + mock_anthropic_stream_with_tools, + is_async, + stream, ): """When no distinct_id is provided and no outer context, trace_id is used and event is personless.""" - with patch( - "anthropic.resources.Messages.create", return_value=mock_anthropic_response - ): - client = Anthropic(api_key="test-key", posthog_client=mock_client) - client.messages.create( - model="claude-3-opus-20240229", - messages=[{"role": "user", "content": "Hello"}], - posthog_trace_id="trace-123", + request = { + "model": "claude-3-opus-20240229", + "messages": [{"role": "user", "content": "Hello"}], + "posthog_trace_id": "trace-123", + "posthog_properties": {"$process_person_profile": True}, + **({"stream": True} if stream else {}), + } + if is_async: + + async def stream_events(): + for event in mock_anthropic_stream_with_tools: + yield event + + async def create(**kwargs): + return stream_events() if stream else mock_anthropic_response + + with patch("anthropic.resources.AsyncMessages.create", side_effect=create): + client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client) + response = await client.messages.create(**request) + if stream: + [event async for event in response] + else: + provider_response = ( + mock_anthropic_stream_with_tools if stream else mock_anthropic_response ) + with patch( + "anthropic.resources.Messages.create", return_value=provider_response + ): + client = Anthropic(api_key="test-key", posthog_client=mock_client) + response = client.messages.create(**request) + if stream: + list(response) - call_args = mock_client.capture.call_args[1] - props = call_args["properties"] - - assert call_args["distinct_id"] == "trace-123" - assert props["$process_person_profile"] is False + call_args = mock_client.capture.call_args[1] + assert call_args["distinct_id"] == "trace-123" + assert call_args["options"] == {"process_person_profile": False} def test_explicit_distinct_id_creates_person_profile( @@ -1775,10 +1802,8 @@ def test_explicit_distinct_id_creates_person_profile( props = call_args["properties"] assert call_args["distinct_id"] == "user-123" - assert ( - "$process_person_profile" not in props - or props["$process_person_profile"] is not False - ) + assert "options" not in call_args + assert "$process_person_profile" not in props def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response): @@ -1799,10 +1824,8 @@ def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response) props = call_args["properties"] assert call_args["distinct_id"] == "outer-user-456" - assert ( - "$process_person_profile" not in props - or props["$process_person_profile"] is not False - ) + assert "options" not in call_args + assert "$process_person_profile" not in props def test_explicit_distinct_id_overrides_outer_context( diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 3dc776002..1fb49c664 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -644,7 +644,7 @@ async def test_data_url_prompt_and_tool_use_base64_are_redacted( class TestPersonlessMode: @pytest.mark.asyncio - async def test_no_distinct_id_sets_process_person_profile_false(self, mock_client): + async def test_no_distinct_id_sets_personless_option(self, mock_client): proc = PostHogClaudeAgentProcessor( client=mock_client, distinct_id=None, @@ -668,8 +668,7 @@ async def test_no_distinct_id_sets_process_person_profile_false(self, mock_clien 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["options"] == {"process_person_profile": False} assert call.kwargs["distinct_id"] == "trace-fallback" diff --git a/posthog/test/ai/gemini/test_gemini.py b/posthog/test/ai/gemini/test_gemini.py index f2a801e68..0079e73db 100644 --- a/posthog/test/ai/gemini/test_gemini.py +++ b/posthog/test/ai/gemini/test_gemini.py @@ -1376,7 +1376,8 @@ def test_embed_content_no_distinct_id( # Should fall back to trace_id as distinct_id assert call_args["distinct_id"] == props["$ai_trace_id"] - assert props["$process_person_profile"] is False + assert call_args["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in props def test_embed_content_default_params( diff --git a/posthog/test/ai/gemini/test_gemini_async.py b/posthog/test/ai/gemini/test_gemini_async.py index 8dc51ca34..1373a331e 100644 --- a/posthog/test/ai/gemini/test_gemini_async.py +++ b/posthog/test/ai/gemini/test_gemini_async.py @@ -995,7 +995,8 @@ async def test_async_embed_content_no_distinct_id( # Should fall back to trace_id as distinct_id assert call_args["distinct_id"] == props["$ai_trace_id"] - assert props["$process_person_profile"] is False + assert call_args["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in props async def test_async_embed_content_default_params( diff --git a/posthog/test/ai/langchain/test_callbacks.py b/posthog/test/ai/langchain/test_callbacks.py index 2556294ae..7bc1b4166 100644 --- a/posthog/test/ai/langchain/test_callbacks.py +++ b/posthog/test/ai/langchain/test_callbacks.py @@ -543,13 +543,13 @@ def test_personless_mode(mock_client): # span assert span_args["event"] == "$ai_span" - assert span_args["properties"]["$process_person_profile"] is False + assert span_args["options"] == {"process_person_profile": False} # generation assert generation_args["event"] == "$ai_generation" - assert generation_args["properties"]["$process_person_profile"] is False + assert generation_args["options"] == {"process_person_profile": False} # trace assert trace_args["event"] == "$ai_trace" - assert trace_args["properties"]["$process_person_profile"] is False + assert trace_args["options"] == {"process_person_profile": False} id = uuid.uuid4() chain.invoke( @@ -561,13 +561,13 @@ def test_personless_mode(mock_client): trace_args = mock_client.capture.call_args_list[5][1] # span - assert "$process_person_profile" not in span_args["properties"] + assert "options" not in span_args assert span_args["distinct_id"] == id # generation - assert "$process_person_profile" not in generation_args["properties"] + assert "options" not in generation_args assert generation_args["distinct_id"] == id # trace - assert "$process_person_profile" not in trace_args["properties"] + assert "options" not in trace_args assert trace_args["distinct_id"] == id @@ -602,13 +602,13 @@ def test_personless_mode_exception(mock_client, unauthorized_http_client): # span assert span_args["event"] == "$ai_span" - assert span_args["properties"]["$process_person_profile"] is False + assert span_args["options"] == {"process_person_profile": False} # generation assert generation_args["event"] == "$ai_generation" - assert generation_args["properties"]["$process_person_profile"] is False + assert generation_args["options"] == {"process_person_profile": False} # trace assert trace_args["event"] == "$ai_trace" - assert trace_args["properties"]["$process_person_profile"] is False + assert trace_args["options"] == {"process_person_profile": False} id = uuid.uuid4() with pytest.raises(AuthenticationError): @@ -622,17 +622,17 @@ def test_personless_mode_exception(mock_client, unauthorized_http_client): # span assert span_args["event"] == "$ai_span" - assert "$process_person_profile" not in span_args["properties"] + assert "options" not in span_args assert span_args["distinct_id"] == id # generation assert generation_args["event"] == "$ai_generation" - assert "$process_person_profile" not in generation_args["properties"] + assert "options" not in generation_args assert generation_args["distinct_id"] == id # trace assert trace_args["event"] == "$ai_trace" - assert "$process_person_profile" not in trace_args["properties"] + assert "options" not in trace_args assert trace_args["distinct_id"] == id diff --git a/posthog/test/ai/openai/test_async_parity.py b/posthog/test/ai/openai/test_async_parity.py index 850b2a885..75ab907c5 100644 --- a/posthog/test/ai/openai/test_async_parity.py +++ b/posthog/test/ai/openai/test_async_parity.py @@ -186,7 +186,8 @@ async def test_embedding_telemetry_has_sync_async_parity(mock_client): assert async_props["$ai_input"] is None assert async_props["$ai_input_tokens"] == 12 assert async_props["$ai_provider"] == "azure" - assert async_props["$process_person_profile"] is False + assert async_capture.kwargs["options"] == sync_capture.kwargs["options"] + assert async_capture.kwargs["options"] == {"process_person_profile": False} assert async_capture.kwargs["distinct_id"] == "shared-trace" assert async_capture.kwargs["groups"] == {"company": "test-company"} diff --git a/posthog/test/ai/openai_agents/test_processor.py b/posthog/test/ai/openai_agents/test_processor.py index 3cbc6d276..69745413b 100644 --- a/posthog/test/ai/openai_agents/test_processor.py +++ b/posthog/test/ai/openai_agents/test_processor.py @@ -157,7 +157,7 @@ def test_personless_mode_when_no_distinct_id(self, mock_client, mock_trace): processor.on_trace_end(mock_trace) call_kwargs = mock_client.capture.call_args[1] - assert call_kwargs["properties"]["$process_person_profile"] is False + assert call_kwargs["options"] == {"process_person_profile": False} # Should fallback to trace_id as the distinct_id assert call_kwargs["distinct_id"] == mock_trace.trace_id @@ -179,7 +179,7 @@ def test_personless_mode_for_spans_when_no_distinct_id( processor.on_span_end(mock_span) call_kwargs = mock_client.capture.call_args[1] - assert call_kwargs["properties"]["$process_person_profile"] is False + assert call_kwargs["options"] == {"process_person_profile": False} assert call_kwargs["distinct_id"] == mock_span.trace_id def test_personless_mode_when_callable_returns_none( @@ -205,7 +205,7 @@ def resolver(trace): processor.on_span_end(mock_span) call_kwargs = mock_client.capture.call_args[1] - assert call_kwargs["properties"]["$process_person_profile"] is False + assert call_kwargs["options"] == {"process_person_profile": False} assert call_kwargs["distinct_id"] == mock_span.trace_id def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace): @@ -219,7 +219,7 @@ def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace) processor.on_trace_end(mock_trace) call_kwargs = mock_client.capture.call_args[1] - assert "$process_person_profile" not in call_kwargs["properties"] + assert "options" not in call_kwargs def test_on_trace_end_clears_metadata(self, processor, mock_client, mock_trace): """Test that on_trace_end clears stored trace metadata.""" diff --git a/posthog/test/mcp/_helpers.py b/posthog/test/mcp/_helpers.py index 836ef8ad6..cd4227c8e 100644 --- a/posthog/test/mcp/_helpers.py +++ b/posthog/test/mcp/_helpers.py @@ -29,7 +29,12 @@ def capture( **kwargs, ): self.events.append( - {"event": event, "distinct_id": distinct_id, "properties": properties or {}} + { + "event": event, + "distinct_id": distinct_id, + "properties": properties or {}, + "options": kwargs.get("options") or {}, + } ) return None diff --git a/posthog/test/mcp/test_fastmcp.py b/posthog/test/mcp/test_fastmcp.py index df725009e..f3e904982 100644 --- a/posthog/test/mcp/test_fastmcp.py +++ b/posthog/test/mcp/test_fastmcp.py @@ -255,7 +255,7 @@ async def test_identify_sets_distinct_id_and_groups(): calls = _events(client, "$mcp_tool_call") assert calls[0]["distinct_id"] == "user_42" assert calls[0]["properties"]["$groups"] == {"organization": "org_7"} - assert "$process_person_profile" not in calls[0]["properties"] + assert calls[0]["options"] == {} # an $identify event was emitted assert _events(client, "$identify") diff --git a/posthog/test/mcp/test_pipeline.py b/posthog/test/mcp/test_pipeline.py index 1cc6008d2..520e8976c 100644 --- a/posthog/test/mcp/test_pipeline.py +++ b/posthog/test/mcp/test_pipeline.py @@ -19,7 +19,10 @@ sanitize_captured_value, sanitize_event, ) -from posthog.mcp._sink import McpCaptureOptions, process_mcp_event +from posthog.capture_event import _to_v1_event +from posthog.client import Client +from posthog.mcp._sink import McpCaptureOptions, McpEventSink, process_mcp_event +from posthog.test.capture_helpers import patch_capture_send, sent_events from posthog.mcp._truncation import MAX_EVENT_BYTES, normalize, truncate_event # --- ids --------------------------------------------------------------------- @@ -895,7 +898,8 @@ def test_build_tool_call_event_properties(): assert props[PostHogMCPAnalyticsProperty.LLM_MODEL_SOURCE] == "client_metadata" assert props[PostHogMCPAnalyticsProperty.SESSION_ID] == "ses_abc" # anonymous (no identity) => person processing disabled - assert props["$process_person_profile"] is False + assert capture["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in props def test_protocol_version_on_primary_and_exception_events(): @@ -931,7 +935,7 @@ def test_identity_enables_person_processing_and_set(): [capture] = build_posthog_capture_events(event) props = capture["properties"] assert capture["distinct_id"] == "user_1" - assert "$process_person_profile" not in props + assert capture["options"] == {} assert props["$set"] == {"email": "a@b.com"} assert props["$groups"] == {"organization": "org_1", "project": "p1"} @@ -1109,3 +1113,32 @@ async def before_send(e): result = await process_mcp_event(event, McpCaptureOptions(before_send=before_send)) _, captures = result assert captures[0]["properties"]["added"] is True + + +def _opt_in(e): + return {**e, "options": {"process_person_profile": True}} + + +@pytest.mark.parametrize( + "before_send, expected", + [(None, False), (_opt_in, True)], + ids=["sdk_option_wins", "before_send_can_change_it"], +) +async def test_anonymous_event_sends_personless_option(before_send, expected): + event = { + "event_type": MCPAnalyticsEventType.MCP_TOOLS_CALL, + "session_id": "ses_abc", + "properties": {"$process_person_profile": True}, + "timestamp": datetime.now(timezone.utc), + } + with patch_capture_send("client") as send: + client = Client( + "test-key", + sync_mode=True, + super_options={"process_person_profile": True}, + ) + await McpEventSink(client).capture( + event, McpCaptureOptions(before_send=before_send) + ) + [wire] = [_to_v1_event(msg) for msg in sent_events(send)] + assert wire["options"] == {"process_person_profile": expected} diff --git a/posthog/test/mcp/test_v2_mcpserver.py b/posthog/test/mcp/test_v2_mcpserver.py index c17e6de15..7fbf0919d 100644 --- a/posthog/test/mcp/test_v2_mcpserver.py +++ b/posthog/test/mcp/test_v2_mcpserver.py @@ -544,7 +544,8 @@ async def test_anonymous_events_do_not_create_person_profiles(): await _flush() calls = _events(client, "$mcp_tool_call") - assert calls[0]["properties"]["$process_person_profile"] is False + assert calls[0]["options"] == {"process_person_profile": False} + assert "$process_person_profile" not in calls[0]["properties"] async def test_a_failed_registry_lookup_delegates_instead_of_swallowing(): diff --git a/posthog/test/test_event_options.py b/posthog/test/test_event_options.py index 4fc89977e..f05aca85c 100644 --- a/posthog/test/test_event_options.py +++ b/posthog/test/test_event_options.py @@ -4,6 +4,7 @@ import pytest from posthog import AsyncPosthog, new_context, set_context_option, tag +from posthog.ai.utils import _capture_ai_event from posthog.capture_event import _to_v1_event from posthog.client import Client from posthog.test.capture_helpers import ( @@ -454,3 +455,31 @@ def call(client): "product_tour_id": "context-tour", } assert not {"$cookieless_mode", "$product_tour_id"} & events[0]["properties"].keys() + + +def _personless_ai_capture(client): + with new_context(fresh=True): + set_context_option(PP, True) + return _capture_ai_event( + client, + "$ai_generation", + distinct_id="trace-1", + properties={"$process_person_profile": True}, + options={PP: True, "cookieless_mode": True}, + personless=True, + ) + + +@pytest.mark.parametrize( + "before_send, expected", + [ + (None, {PP: False, "cookieless_mode": True}), + (lambda m: {**m, "options": {PP: True}}, {PP: True}), + ], + ids=["sdk_option_wins", "before_send_can_change_it"], +) +def test_personless_ai_event_overrides_every_user_layer(before_send, expected): + events = _sync_wire_events( + _personless_ai_capture, before_send=before_send, super_options={PP: True} + ) + assert events[0]["options"] == expected