diff --git a/.sampo/changesets/public-capture-ai-beta.md b/.sampo/changesets/public-capture-ai-beta.md new file mode 100644 index 00000000..1d2d39bf --- /dev/null +++ b/.sampo/changesets/public-capture-ai-beta.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Public beta `capture_ai`: AI events on the dedicated AI endpoint with the event UUID returned; new `enable_full_ai_capture` flag (old private flags kept as deprecated aliases). diff --git a/posthog/__init__.py b/posthog/__init__.py index cd8bd004..a1825821 100644 --- a/posthog/__init__.py +++ b/posthog/__init__.py @@ -401,12 +401,10 @@ def get_tags() -> Dict[str, Any]: # Capture wire protocol for the global client. None defers to POSTHOG_CAPTURE_MODE # then CaptureMode.V0. See posthog.capture_mode.CaptureMode. capture_mode = None # type: Optional[CaptureMode] -# Internal, no stability guarantees. `_use_ai_lane` routes AI SDK wrapper events -# through the dedicated AI capture lane; `_enable_multimodal_capture` additionally -# skips media redaction (and implies the lane). Module attributes so the lazily -# auto-instantiated default client can be configured without constructing it. -# Like `debug`/`disabled`, these are authoritative for the default client: -# `setup()` re-syncs them onto it on every call, overwriting direct assignments. +# Routes AI SDK wrapper events through the dedicated AI capture lane, skips +# truncation, and passes media unredacted. `privacy_mode` always wins. +enable_full_ai_capture = False # type: bool +# Deprecated aliases for `enable_full_ai_capture`. _use_ai_lane = False # type: bool _enable_multimodal_capture = False # type: bool @@ -503,6 +501,41 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: return _proxy("capture", event, **kwargs) +def capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]: + """ + Capture an AI event on the dedicated AI capture endpoint. + + Beta: the signature is stable; operational limits (per-event size cap, + batching, endpoint) may change without notice. + + Takes the same arguments and returns the same value as `capture()`: the + event UUID, or None when the event was not admitted (disabled client, or + dropped by `before_send`). The event is delivered on an isolated queue + with its own consumer pool and a higher per-event size cap, posting to + the dedicated AI ingestion endpoint. The payload is sent as given — no + redaction or truncation is applied here. + + Args: + event: The event name, normally one of the `$ai_*` event names. + **kwargs: Same optional arguments as `capture()`. + + Examples: + ```python + from posthog import capture_ai + + uuid = capture_ai( + "$ai_generation", + distinct_id="user_123", + properties={"$ai_model": "gpt-5"}, + ) + ``` + + Category: + Events + """ + return _proxy("capture_ai", event, **kwargs) + + def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]: """ Set properties on a user record. @@ -1237,8 +1270,11 @@ def setup() -> Client: default_client.debug = debug default_client.privacy_mode = bool(privacy_mode) default_client._set_before_send(before_send) - default_client._use_ai_lane = bool(_use_ai_lane) - default_client._enable_multimodal_capture = bool(_enable_multimodal_capture) + default_client.enable_full_ai_capture = ( + bool(enable_full_ai_capture) + or bool(_use_ai_lane) + or bool(_enable_multimodal_capture) + ) # Metrics config is consumed lazily on first `.metrics` access, so late # module-attr assignment (e.g. a Django ready() hook running after something # already forced setup()) still applies until the metrics API is first used. diff --git a/posthog/ai/openai_agents/processor.py b/posthog/ai/openai_agents/processor.py index 054f023c..b075c02f 100644 --- a/posthog/ai/openai_agents/processor.py +++ b/posthog/ai/openai_agents/processor.py @@ -21,7 +21,7 @@ from posthog import setup from posthog.ai.media import ensure_serializable as _ensure_serializable -from posthog.ai.sanitization import _multimodal_capture_enabled, _placeholder +from posthog.ai.sanitization import _full_ai_capture_enabled, _placeholder from posthog.ai.utils import _capture_ai_event, finalize_ai_content from posthog.client import Client @@ -801,7 +801,7 @@ def _handle_audio_span( if span_type == "transcription": audio_input: Any = ( span_data.input - if _multimodal_capture_enabled(self._client) + if _full_ai_capture_enabled(self._client) else _placeholder( getattr(span_data, "input_format", None) or "audio" ) diff --git a/posthog/ai/sanitization.py b/posthog/ai/sanitization.py index 8989c61a..09fcf0a4 100644 --- a/posthog/ai/sanitization.py +++ b/posthog/ai/sanitization.py @@ -32,10 +32,10 @@ _MEDIA_URL_CONTAINER_KEYS = {"image_url", "imageUrl", "video_url", "videoUrl"} -def _multimodal_capture_enabled(ph_client: Any = None) -> bool: - """Media passthrough: on only when the client opted into multimodal capture.""" +def _full_ai_capture_enabled(ph_client: Any = None) -> bool: + """Full AI capture: no truncation and media passthrough, on only when the client opted in.""" return ( - getattr(ph_client, "_enable_multimodal_capture", False) is True + getattr(ph_client, "enable_full_ai_capture", False) is True ) # is True: tolerate unspecced Mock clients whose auto-generated attrs are truthy @@ -131,7 +131,7 @@ def _redact_string( def redact_media( value: Any, max_string_len: Optional[int] = None, ph_client: Any = None ) -> Any: - passthrough = _multimodal_capture_enabled(ph_client) + passthrough = _full_ai_capture_enabled(ph_client) stack: set = set() def walk( diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index 057fec63..dd112019 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -4,7 +4,7 @@ from posthog import get_tags, identify_context, new_context, tag, contexts from posthog.ai.gateway import warn_if_posthog_ai_gateway -from posthog.ai.sanitization import _multimodal_capture_enabled, redact_media +from posthog.ai.sanitization import _full_ai_capture_enabled, redact_media from posthog.ai.sanitization import sanitize_messages # noqa: F401 -- re-exported for back-compat from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage from posthog.client import Client as PostHogClient @@ -57,21 +57,14 @@ def _get_tokens_source( def _ai_lane_enabled(ph_client) -> bool: - """The client's private, unstable AI-lane opt-in; multimodal implies it.""" - # `is True` tolerates unspecced Mock clients whose auto-generated attrs are truthy. - opted_in = getattr(ph_client, "_use_ai_lane", False) is True - return opted_in or _multimodal_capture_enabled(ph_client) + """The client's full-AI-capture opt-in routes wrapper events onto the AI lane.""" + return _full_ai_capture_enabled(ph_client) def _capture_ai_event(ph_client, event: str, **kwargs): - """Capture a wrapper-emitted AI event. - - When the client opted into the AI lane, the event rides it via - `_capture_ai`. Otherwise — including duck-typed client-likes without the - lane — events keep the plain `capture()` path they have today. - """ + """Capture a wrapper-emitted AI event, falling back to `capture()` for duck-typed clients without `capture_ai`.""" if _ai_lane_enabled(ph_client): - capture_ai = getattr(ph_client, "_capture_ai", None) + capture_ai = getattr(ph_client, "capture_ai", None) if callable(capture_ai): return capture_ai(event=event, **kwargs) return ph_client.capture(event=event, **kwargs) diff --git a/posthog/client.py b/posthog/client.py index 7f423130..b33ceec4 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -633,6 +633,7 @@ def __init__( 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, ): @@ -693,6 +694,11 @@ def __init__( captured exceptions. Defaults to the current working directory. privacy_mode: For AI observability, capture usage metadata without prompt inputs or outputs. + enable_full_ai_capture: Route PostHog AI wrapper events through + the dedicated AI capture endpoint and capture full AI content: + skips string truncation and passes media (base64/data URIs) + through unredacted. ``privacy_mode`` always wins. Defaults to + False. before_send: Optional callback that can modify or drop events before upload. Return ``None`` to drop an event. flag_fallback_cache_url: Optional feature flag fallback cache URL, @@ -814,12 +820,12 @@ def __init__( self._metrics_config = metrics self._metrics: Optional[PostHogMetrics] = None self._metrics_lock = threading.Lock() - # Internal, no stability guarantees. `_use_ai_lane` routes all AI SDK - # wrapper events through the dedicated AI lane; `_enable_multimodal_capture` - # additionally skips media redaction (and implies the lane). Both are - # read per event by wrapper-layer code, never by `capture()` itself. - self._use_ai_lane = bool(_use_ai_lane) - self._enable_multimodal_capture = bool(_enable_multimodal_capture) + # `_use_ai_lane` / `_enable_multimodal_capture` are deprecated aliases. + self.enable_full_ai_capture = ( + enable_full_ai_capture is True + or _use_ai_lane is True + or _enable_multimodal_capture is True + ) self.is_server = is_server self.historical_migration = historical_migration # Selects the capture wire protocol (V0 legacy `/batch/` vs V1 @@ -992,6 +998,24 @@ def consumers(self) -> Optional[List[Consumer]]: return None return [consumer for lane in self._lanes for consumer in lane.consumers] + @property + def _use_ai_lane(self) -> bool: + """Deprecated alias for `enable_full_ai_capture`.""" + return self.enable_full_ai_capture + + @_use_ai_lane.setter + def _use_ai_lane(self, value) -> None: + self.enable_full_ai_capture = value is True + + @property + def _enable_multimodal_capture(self) -> bool: + """Deprecated alias for `enable_full_ai_capture`.""" + return self.enable_full_ai_capture + + @_enable_multimodal_capture.setter + def _enable_multimodal_capture(self, value) -> None: + self.enable_full_ai_capture = value is True + def _warn_if_duplicate_async_client(self): if self.disabled or not self.send or self.sync_mode or not self.api_key: return @@ -1453,22 +1477,27 @@ def capture( return self._capture(event, self._analytics_lane, **kwargs) @no_throw() - def _capture_ai( + def capture_ai( self, event: str, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: - """Capture an AI event on the dedicated AI lane. + """Capture an AI event on the dedicated AI capture endpoint. + + Beta: the signature is stable; operational limits (per-event size + cap, batching, endpoint) may change without notice. - Internal and experimental, with no stability guarantees: the signature - and lane behavior may change while the AI capture lane is validated on - PostHog's own traffic. + Takes the same arguments and returns the same value as `capture()`: + the event UUID, or None when the event was not admitted (disabled + client, or dropped by `before_send`). The event is queued on an + isolated AI lane with its own consumer pool and a higher per-event + size cap, posting to the dedicated AI ingestion endpoint. The payload + is sent as given — no redaction or truncation is applied here. - Takes the same arguments and returns the same value as `capture()`, - but the event is queued on the AI lane, which posts to the dedicated - AI endpoint with its own consumer pool and per-event size cap. + Category: + Capture """ if not event.startswith("$ai_"): self.log.debug( - "_capture_ai called with non-AI event name %r; routing it to the AI endpoint anyway.", + "capture_ai called with non-AI event name %r; routing it to the AI endpoint anyway.", event, ) return self._capture(event, self._ai_lane, **kwargs) @@ -1476,7 +1505,7 @@ def _capture_ai( def _capture( self, event: str, lane: _Lane, **kwargs: Unpack[OptionalCaptureArgs] ) -> Optional[str]: - """Shared message-building body of `capture()` and `_capture_ai()`; `lane` picks the wire destination.""" + """Shared message-building body of `capture()` and `capture_ai()`; `lane` picks the wire destination.""" distinct_id = kwargs.get("distinct_id", None) properties = kwargs.get("properties", None) timestamp = kwargs.get("timestamp", None) @@ -2118,6 +2147,21 @@ def _reinit_after_fork(self): else: self.poller = None + def _normalize_event_uuid(self, msg): + # type: (...) -> None + """Ensure `msg["uuid"]` is a valid uuid string, generating one if missing or invalid.""" + if "uuid" in msg: + uuid = msg.pop("uuid") + if uuid is not None: + try: + msg["uuid"] = _stringify_event_uuid(uuid) + except ValueError as e: + self.log.error("%s Falling back to a generated UUID.", e) + + if "uuid" not in msg: + # Always send a uuid, so we can always return one + msg["uuid"] = stringify_id(uuid4()) + def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): # type: (...) -> Optional[str] """Push a new `msg` onto a lane's queue (analytics when unspecified), return the event uuid or None.""" @@ -2136,19 +2180,7 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): timestamp = guess_timezone(timestamp) msg["timestamp"] = timestamp.isoformat() - if "uuid" in msg: - uuid = msg.pop("uuid") - if uuid is not None: - try: - msg["uuid"] = _stringify_event_uuid(uuid) - except ValueError as e: - self.log.error("%s Falling back to a generated UUID.", e) - - if "uuid" not in msg: - # Always send a uuid, so we can always return one - msg["uuid"] = stringify_id(uuid4()) - - sent_uuid = msg["uuid"] + self._normalize_event_uuid(msg) if not msg.get("properties"): msg["properties"] = {} @@ -2194,6 +2226,11 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): self.log.exception(f"Error in before_send callback: {e}") # Continue with the original message if callback fails + # Re-normalized after before_send, which may have replaced or removed + # msg["uuid"], so the returned uuid always matches the wire event. + self._normalize_event_uuid(msg) + sent_uuid = msg["uuid"] + self.log.debug("queueing: %s", msg) # if send is False, return msg as if it was successfully queued, unless diff --git a/posthog/test/ai/anthropic/test_anthropic.py b/posthog/test/ai/anthropic/test_anthropic.py index 0fb840f8..f7322a63 100644 --- a/posthog/test/ai/anthropic/test_anthropic.py +++ b/posthog/test/ai/anthropic/test_anthropic.py @@ -1970,7 +1970,7 @@ async def mock_async_create(**kwargs): def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_anthropic_response): - mock_client._use_ai_lane = True + mock_client.enable_full_ai_capture = True with patch( "anthropic.resources.Messages.create", return_value=mock_anthropic_response ): @@ -1982,12 +1982,12 @@ def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_anthropic_re ) mock_client.capture.assert_not_called() - assert mock_client._capture_ai.call_count == 1 - assert mock_client._capture_ai.call_args[1]["event"] == "$ai_generation" + assert mock_client.capture_ai.call_count == 1 + assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation" def test_multimodal_client_skips_media_redaction(mock_client, mock_anthropic_response): - mock_client._enable_multimodal_capture = True + mock_client.enable_full_ai_capture = True image_data = "A" * 64 with patch( @@ -2015,6 +2015,6 @@ def test_multimodal_client_skips_media_redaction(mock_client, mock_anthropic_res ) mock_client.capture.assert_not_called() - assert mock_client._capture_ai.call_count == 1 - props = mock_client._capture_ai.call_args[1]["properties"] + assert mock_client.capture_ai.call_count == 1 + props = mock_client.capture_ai.call_args[1]["properties"] assert props["$ai_input"][0]["content"][0]["source"]["data"] == image_data diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 7b6f7fd9..97402a74 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -785,10 +785,10 @@ async def test_non_config_errors_propagate(self): def test_ai_lane_client_routes_through_capture_ai(mock_client): - mock_client._use_ai_lane = True + mock_client.enable_full_ai_capture = True processor = PostHogClaudeAgentProcessor(client=mock_client, distinct_id="test-user") processor._capture_event(event="$ai_trace", properties={}, distinct_id="d") mock_client.capture.assert_not_called() - mock_client._capture_ai.assert_called_once() - assert mock_client._capture_ai.call_args[1]["event"] == "$ai_trace" + mock_client.capture_ai.assert_called_once() + assert mock_client.capture_ai.call_args[1]["event"] == "$ai_trace" diff --git a/posthog/test/ai/claude_agent_sdk/test_processor_content.py b/posthog/test/ai/claude_agent_sdk/test_processor_content.py index 1ad190b5..ded5ee91 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor_content.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor_content.py @@ -73,7 +73,7 @@ def test_empty_string_tool_content_not_none(mod): def test_passthrough_tool_result_media_not_truncated(mod): # A client opted into multimodal capture must not get its tool-result media # cut at max_string_len — a 5000-char slice through base64 corrupts it. - client = types.SimpleNamespace(_enable_multimodal_capture=True) + client = types.SimpleNamespace(enable_full_ai_capture=True) long_b64 = "A" * 6000 block = FakeToolResultBlock( content=[ diff --git a/posthog/test/ai/gemini/test_gemini.py b/posthog/test/ai/gemini/test_gemini.py index 8bb3ddff..f938f618 100644 --- a/posthog/test/ai/gemini/test_gemini.py +++ b/posthog/test/ai/gemini/test_gemini.py @@ -1584,7 +1584,7 @@ def test_ai_lane_client_routes_through_capture_ai( ): mock_google_genai_client.models.generate_content.return_value = mock_gemini_response - mock_client._use_ai_lane = True + mock_client.enable_full_ai_capture = True client = Client(api_key="test-key", posthog_client=mock_client) client.models.generate_content( model="gemini-2.0-flash", @@ -1593,5 +1593,5 @@ def test_ai_lane_client_routes_through_capture_ai( ) mock_client.capture.assert_not_called() - assert mock_client._capture_ai.call_count == 1 - assert mock_client._capture_ai.call_args[1]["event"] == "$ai_generation" + assert mock_client.capture_ai.call_count == 1 + assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation" diff --git a/posthog/test/ai/langchain/test_callbacks.py b/posthog/test/ai/langchain/test_callbacks.py index 5a3a1bd0..577178fc 100644 --- a/posthog/test/ai/langchain/test_callbacks.py +++ b/posthog/test/ai/langchain/test_callbacks.py @@ -2825,12 +2825,12 @@ def failing_span(_): def test_ai_lane_client_routes_through_capture_ai(mock_client): prompt = ChatPromptTemplate.from_messages([("user", "Who won the world series?")]) model = FakeMessagesListChatModel(responses=[AIMessage(content="The Dodgers.")]) - mock_client._use_ai_lane = True + mock_client.enable_full_ai_capture = True callbacks = [CallbackHandler(mock_client)] (prompt | model).invoke({}, config={"callbacks": callbacks}) mock_client.capture.assert_not_called() - events = [c[1]["event"] for c in mock_client._capture_ai.call_args_list] + events = [c[1]["event"] for c in mock_client.capture_ai.call_args_list] assert "$ai_generation" in events assert "$ai_trace" in events diff --git a/posthog/test/ai/openai/test_openai.py b/posthog/test/ai/openai/test_openai.py index 83d4e58f..e911d77b 100644 --- a/posthog/test/ai/openai/test_openai.py +++ b/posthog/test/ai/openai/test_openai.py @@ -2434,7 +2434,7 @@ async def mock_create(self, **kwargs): def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_openai_response): - mock_client._use_ai_lane = True + mock_client.enable_full_ai_capture = True with patch( "openai.resources.chat.completions.Completions.create", return_value=mock_openai_response, @@ -2447,12 +2447,12 @@ def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_openai_respo ) mock_client.capture.assert_not_called() - assert mock_client._capture_ai.call_count == 1 - assert mock_client._capture_ai.call_args[1]["event"] == "$ai_generation" + assert mock_client.capture_ai.call_count == 1 + assert mock_client.capture_ai.call_args[1]["event"] == "$ai_generation" def test_multimodal_client_skips_media_redaction(mock_client, mock_openai_response): - mock_client._enable_multimodal_capture = True + mock_client.enable_full_ai_capture = True image = "data:image/jpeg;base64," + "A" * 64 with patch( @@ -2474,9 +2474,9 @@ def test_multimodal_client_skips_media_redaction(mock_client, mock_openai_respon ) mock_client.capture.assert_not_called() - assert mock_client._capture_ai.call_count == 1 + assert mock_client.capture_ai.call_count == 1 - call_args = mock_client._capture_ai.call_args[1] + call_args = mock_client.capture_ai.call_args[1] props = call_args["properties"] assert props["$ai_input"][0]["content"][0]["image_url"]["url"] == image diff --git a/posthog/test/ai/openai_agents/test_processor.py b/posthog/test/ai/openai_agents/test_processor.py index d1c38186..ebff6237 100644 --- a/posthog/test/ai/openai_agents/test_processor.py +++ b/posthog/test/ai/openai_agents/test_processor.py @@ -455,7 +455,7 @@ def test_generation_span_bytes_input_becomes_base64_in_passthrough_mode( self, processor, mock_client, mock_span ): """Test that raw bytes content becomes a base64 string under multimodal passthrough.""" - mock_client._enable_multimodal_capture = True + mock_client.enable_full_ai_capture = True raw = b"\x00\x01\x02\x03" span_data = GenerationSpanData( input=[{"role": "user", "content": raw}], @@ -467,7 +467,7 @@ def test_generation_span_bytes_input_becomes_base64_in_passthrough_mode( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client._capture_ai.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] captured_input = call_kwargs["properties"]["$ai_input"] assert captured_input[0]["content"] == base64.b64encode(raw).decode() @@ -773,7 +773,7 @@ def test_transcription_span_audio_input_passthrough( self, processor, mock_client, mock_span ): """Under multimodal passthrough the raw audio is kept intact.""" - mock_client._enable_multimodal_capture = True + mock_client.enable_full_ai_capture = True b64 = base64.b64encode(b"\x00" * 1000).decode() span_data = TranscriptionSpanData( input=b64, @@ -786,7 +786,7 @@ def test_transcription_span_audio_input_passthrough( processor.on_span_start(mock_span) processor.on_span_end(mock_span) - call_kwargs = mock_client._capture_ai.call_args[1] + call_kwargs = mock_client.capture_ai.call_args[1] assert call_kwargs["properties"]["$ai_input"] == b64 def test_latency_calculation(self, processor, mock_client, mock_span): @@ -1001,11 +1001,11 @@ def test_instrument_with_groups_and_properties(self, mock_client): def test_ai_lane_client_routes_through_capture_ai(mock_client, mock_trace): - mock_client._use_ai_lane = True + mock_client.enable_full_ai_capture = True processor = PostHogTracingProcessor(client=mock_client, distinct_id="test-user") processor.on_trace_start(mock_trace) processor.on_trace_end(mock_trace) mock_client.capture.assert_not_called() - mock_client._capture_ai.assert_called_once() - assert mock_client._capture_ai.call_args[1]["event"] == "$ai_trace" + mock_client.capture_ai.assert_called_once() + assert mock_client.capture_ai.call_args[1]["event"] == "$ai_trace" diff --git a/posthog/test/ai/test_capture_pipeline.py b/posthog/test/ai/test_capture_pipeline.py index 3621f739..916ad841 100644 --- a/posthog/test/ai/test_capture_pipeline.py +++ b/posthog/test/ai/test_capture_pipeline.py @@ -323,7 +323,7 @@ def test_langchain_callback_output_choices_image_passthrough_when_multimodal_ena from posthog.ai.langchain.callbacks import CallbackHandler - fake_ph._enable_multimodal_capture = True + fake_ph.enable_full_ai_capture = True cb = CallbackHandler(client=fake_ph) run_id = uuid4() diff --git a/posthog/test/ai/test_sanitization.py b/posthog/test/ai/test_sanitization.py index d538e181..de386023 100644 --- a/posthog/test/ai/test_sanitization.py +++ b/posthog/test/ai/test_sanitization.py @@ -345,7 +345,7 @@ def test_sanitize_handles_single_message(self): class TestClientMultimodalPassthrough(unittest.TestCase): - """Multimodal passthrough is gated on the client's _enable_multimodal_capture.""" + """Multimodal passthrough is gated on the client's enable_full_ai_capture.""" def setUp(self): self.image = "data:image/jpeg;base64," + "A" * 64 @@ -357,7 +357,7 @@ def setUp(self): ] def _client(self, enabled): - return types.SimpleNamespace(_enable_multimodal_capture=enabled) + return types.SimpleNamespace(enable_full_ai_capture=enabled) def test_flag_preserves_media_across_entry_points(self): client = self._client(True) @@ -429,7 +429,7 @@ def test_unspecced_mock_client_still_redacts(self): class TestAudioRedaction(unittest.TestCase): def _client(self, enabled): - return types.SimpleNamespace(_enable_multimodal_capture=enabled) + return types.SimpleNamespace(enable_full_ai_capture=enabled) def test_openai_audio_redacted_by_default(self): input_data = [ @@ -594,7 +594,7 @@ def test_bytes_redacted_by_default(self): assert out["inline_data"]["data"] == "[base64 video redacted]" def test_bytes_base64d_in_passthrough(self): - client = types.SimpleNamespace(_enable_multimodal_capture=True) + client = types.SimpleNamespace(enable_full_ai_capture=True) raw = b"\x00\x01\x02" out = redact_media( {"inline_data": {"mime_type": "video/mp4", "data": raw}}, ph_client=client @@ -602,7 +602,7 @@ def test_bytes_base64d_in_passthrough(self): assert out["inline_data"]["data"] == base64.b64encode(raw).decode() def test_passthrough_leaves_strings(self): - client = types.SimpleNamespace(_enable_multimodal_capture=True) + client = types.SimpleNamespace(enable_full_ai_capture=True) val = {"inline_data": {"mime_type": "image/png", "data": PNG_B64}} assert redact_media(val, ph_client=client) == val diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index 61fa203b..a11379be 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -1,10 +1,11 @@ import threading import unittest +import uuid from unittest import mock import posthog -from posthog.ai.utils import _capture_ai_event +from posthog.ai.utils import _capture_ai_event, finalize_ai_content, with_privacy_mode from posthog.capture_mode import CaptureMode from posthog.client import Client from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE @@ -29,7 +30,7 @@ def test_capture_ai_and_capture_ride_separate_lanes(self): client = self._client() with mock.patch("posthog.consumer.batch_post") as mock_post: client.capture("button_clicked", distinct_id="d") - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") client.flush() by_path = _events_by_path(mock_post) @@ -51,7 +52,7 @@ def test_capture_ai_and_capture_ride_separate_lanes(self): def test_capture_does_not_reroute_ai_named_events(self): # The two-lane rule: `capture()` never special-cases AI events, no - # matter their name. Only `_capture_ai()` reaches the AI lane. + # matter their name. Only `capture_ai()` reaches the AI lane. client = self._client() with mock.patch("posthog.consumer.batch_post") as mock_post: client.capture("$ai_generation", distinct_id="d") @@ -64,13 +65,13 @@ def test_capture_does_not_reroute_ai_named_events(self): def test_capture_ai_returns_event_uuid_like_capture(self): client = self._client(send=False) - uuid = client._capture_ai("$ai_generation", distinct_id="d") + uuid = client.capture_ai("$ai_generation", distinct_id="d") self.assertIsNotNone(uuid) def test_sync_mode_capture_ai_posts_single_event_batch_to_ai_endpoint(self): client = Client(TEST_API_KEY, sync_mode=True) with mock.patch("posthog.client.batch_post") as mock_post: - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") mock_post.assert_called_once() self.assertEqual(mock_post.call_args.kwargs["path"], AI_EVENTS_ENDPOINT) @@ -78,7 +79,7 @@ def test_sync_mode_capture_ai_posts_single_event_batch_to_ai_endpoint(self): self.assertEqual([e["event"] for e in batch], ["$ai_generation"]) def test_multimodal_client_routes_wrapper_captures_to_ai_lane(self): - client = self._client(_enable_multimodal_capture=True) + client = self._client(enable_full_ai_capture=True) with mock.patch("posthog.consumer.batch_post") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") client.flush() @@ -87,7 +88,7 @@ def test_multimodal_client_routes_wrapper_captures_to_ai_lane(self): def test_disabled_client_never_starts_ai_lane(self): client = Client(TEST_API_KEY, disabled=True) - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") self.assertEqual(client._ai_lane.consumers, []) def test_posthog_alias_accepts_private_kwargs(self): @@ -209,7 +210,7 @@ def test_async_ai_events_use_v0_even_with_capture_mode_v1(self): mock.patch("posthog.consumer.batch_post") as mock_post, mock.patch("posthog.consumer._send_v1_batch") as mock_v1, ): - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") client.flush() @@ -226,7 +227,7 @@ def test_sync_ai_events_use_v0_even_with_capture_mode_v1(self): mock.patch("posthog.client.batch_post") as mock_post, mock.patch("posthog.client._send_v1_batch") as mock_v1, ): - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") client.capture("button_clicked", distinct_id="d") mock_post.assert_called_once() @@ -245,7 +246,7 @@ def test_no_ai_consumers_until_first_capture_ai(self): self.assertEqual(client._ai_lane.consumers, []) with mock.patch("posthog.consumer.batch_post"): - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") self.assertEqual(len(client._ai_lane.consumers), 1) self.assertTrue(client._ai_lane.consumers[0].is_alive()) client.flush() @@ -257,7 +258,7 @@ def test_concurrent_first_captures_start_exactly_one_pool(self): def fire(): barrier.wait() - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") threads = [threading.Thread(target=fire) for _ in range(8)] with mock.patch("posthog.consumer.batch_post"): @@ -284,7 +285,7 @@ def test_fork_rebuild_restarts_analytics_and_resets_ai(self): TEST_API_KEY, flush_interval=0.05, enable_local_evaluation=False ) with mock.patch("posthog.consumer.batch_post"): - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(len(client._ai_lane.consumers), 1) @@ -305,7 +306,7 @@ def test_fork_rebuild_restarts_analytics_and_resets_ai(self): with mock.patch("posthog.consumer.batch_post") as mock_post: client.capture("button_clicked", distinct_id="d") - client._capture_ai("$ai_generation", distinct_id="d") + client.capture_ai("$ai_generation", distinct_id="d") client.flush() self.assertEqual(len(client._ai_lane.consumers), 1) @@ -329,7 +330,7 @@ class TestCaptureAiEventHelper(unittest.TestCase): """`_capture_ai_event` rides the AI lane only when the client opted in.""" def test_opted_in_routes_through_ai_lane(self): - client = Client(TEST_API_KEY, flush_interval=0.05, _use_ai_lane=True) + client = Client(TEST_API_KEY, flush_interval=0.05, enable_full_ai_capture=True) with mock.patch("posthog.consumer.batch_post") as mock_post: _capture_ai_event( client, @@ -364,39 +365,35 @@ def test_default_mock_clients_keep_seeing_capture(self): client = mock.Mock() _capture_ai_event(client, "$ai_generation", distinct_id="d") client.capture.assert_called_once_with(event="$ai_generation", distinct_id="d") - client._capture_ai.assert_not_called() + client.capture_ai.assert_not_called() def test_opted_in_prefers_capture_ai(self): - client = mock.Mock(spec=["capture", "_capture_ai", "_use_ai_lane"]) - client._use_ai_lane = True + client = mock.Mock(spec=["capture", "capture_ai", "enable_full_ai_capture"]) + client.enable_full_ai_capture = True _capture_ai_event(client, "$ai_generation", distinct_id="d") - client._capture_ai.assert_called_once_with( + client.capture_ai.assert_called_once_with( event="$ai_generation", distinct_id="d" ) client.capture.assert_not_called() def test_opted_in_duck_typed_client_without_method_falls_back(self): - client = mock.Mock(spec=["capture", "_use_ai_lane"]) - client._use_ai_lane = True + client = mock.Mock(spec=["capture", "enable_full_ai_capture"]) + client.enable_full_ai_capture = True _capture_ai_event(client, "$ai_generation", distinct_id="d") client.capture.assert_called_once_with(event="$ai_generation", distinct_id="d") def test_client_multimodal_flag_prefers_capture_ai(self): - client = mock.Mock( - spec=["capture", "_capture_ai", "_enable_multimodal_capture"] - ) - client._enable_multimodal_capture = True + client = mock.Mock(spec=["capture", "capture_ai", "enable_full_ai_capture"]) + client.enable_full_ai_capture = True _capture_ai_event(client, "$ai_generation", distinct_id="d") - client._capture_ai.assert_called_once_with( + client.capture_ai.assert_called_once_with( event="$ai_generation", distinct_id="d" ) client.capture.assert_not_called() def test_client_multimodal_flag_off_keeps_capture(self): - client = mock.Mock( - spec=["capture", "_capture_ai", "_enable_multimodal_capture"] - ) - client._enable_multimodal_capture = False + client = mock.Mock(spec=["capture", "capture_ai", "enable_full_ai_capture"]) + client.enable_full_ai_capture = False _capture_ai_event(client, "$ai_generation", distinct_id="d") client.capture.assert_called_once_with(event="$ai_generation", distinct_id="d") @@ -406,7 +403,7 @@ class TestLanesRefuseWorkAfterShutdown(unittest.TestCase): afterwards, even a lazy AI lane that never started before shutdown.""" def test_late_ai_capture_after_shutdown_starts_nothing_and_sends_nothing(self): - client = Client(TEST_API_KEY, _use_ai_lane=True, flush_interval=0.05) + client = Client(TEST_API_KEY, enable_full_ai_capture=True, flush_interval=0.05) client.shutdown() with mock.patch("posthog.consumer.batch_post") as mock_post: _capture_ai_event(client, "$ai_generation", distinct_id="d") @@ -425,9 +422,7 @@ def test_late_analytics_capture_after_shutdown_drops_with_warning(self): class TestModuleLevelFlagConfig(unittest.TestCase): - """The lazily auto-instantiated default client picks the private AI flags - up from module attributes, so deployments configuring PostHog via - `posthog. = ...` never need to construct or mutate a client.""" + """The default client picks up the AI capture flag and its deprecated aliases from module attributes.""" def setUp(self): self._saved = { @@ -440,6 +435,7 @@ def setUp(self): posthog.send = False def tearDown(self): + posthog.enable_full_ai_capture = False posthog._use_ai_lane = False posthog._enable_multimodal_capture = False posthog.default_client = self._saved["default_client"] @@ -450,7 +446,7 @@ def test_setup_applies_module_flags_to_new_default_client(self): posthog._use_ai_lane = True client = posthog.setup() self.assertTrue(client._use_ai_lane) - self.assertFalse(client._enable_multimodal_capture) + self.assertTrue(client._enable_multimodal_capture) def test_setup_resyncs_flags_on_existing_default_client(self): client = posthog.setup() @@ -463,5 +459,155 @@ def test_setup_resyncs_flags_on_existing_default_client(self): self.assertTrue(client._enable_multimodal_capture) +class TestFullAiCaptureFlag(unittest.TestCase): + def _client(self, **kwargs): + client = Client(TEST_API_KEY, flush_interval=0.05, **kwargs) + self.addCleanup(client.join) + return client + + def test_new_flag_routes_wrapper_captures_to_ai_lane(self): + client = self._client(enable_full_ai_capture=True) + with mock.patch("posthog.consumer.batch_post") as mock_post: + _capture_ai_event(client, "$ai_generation", distinct_id="d") + client.flush() + self.assertEqual(set(_events_by_path(mock_post)), {AI_EVENTS_ENDPOINT}) + + def test_deprecated_kwargs_map_to_new_flag(self): + for kwargs in ({"_use_ai_lane": True}, {"_enable_multimodal_capture": True}): + client = Client(TEST_API_KEY, send=False, **kwargs) + self.addCleanup(client.join) + self.assertTrue(client.enable_full_ai_capture) + + def test_alias_properties_read_and_write_the_new_flag(self): + client = Client(TEST_API_KEY, send=False) + self.addCleanup(client.join) + self.assertFalse(client._use_ai_lane) + self.assertFalse(client._enable_multimodal_capture) + client._enable_multimodal_capture = True + self.assertTrue(client.enable_full_ai_capture) + self.assertTrue(client._use_ai_lane) + + def test_module_globals_sync_onto_default_client(self): + previous = ( + posthog.default_client, + posthog.project_api_key, + posthog.enable_full_ai_capture, + posthog._use_ai_lane, + ) + try: + posthog.default_client = Client(TEST_API_KEY, send=False) + posthog.project_api_key = TEST_API_KEY + posthog.enable_full_ai_capture = True + posthog.setup() + self.assertTrue(posthog.default_client.enable_full_ai_capture) + posthog.enable_full_ai_capture = False + posthog._use_ai_lane = True + posthog.setup() + self.assertTrue(posthog.default_client.enable_full_ai_capture) + finally: + ( + posthog.default_client, + posthog.project_api_key, + posthog.enable_full_ai_capture, + posthog._use_ai_lane, + ) = previous + + +class TestPublicCaptureAi(unittest.TestCase): + def test_capture_ai_is_public_and_returns_uuid(self): + client = Client(TEST_API_KEY, send=False) + self.addCleanup(client.join) + self.assertIsNotNone(client.capture_ai("$ai_generation", distinct_id="d")) + self.assertFalse(hasattr(client, "_capture_ai")) + + def test_module_level_capture_ai_returns_uuid(self): + previous = (posthog.default_client, posthog.project_api_key) + try: + posthog.default_client = Client(TEST_API_KEY, send=False) + posthog.project_api_key = TEST_API_KEY + self.assertIsNotNone(posthog.capture_ai("$ai_generation", distinct_id="d")) + finally: + posthog.default_client, posthog.project_api_key = previous + + +class TestCaptureAiUuid(unittest.TestCase): + def _client(self, **kwargs): + client = Client(TEST_API_KEY, flush_interval=0.05, **kwargs) + self.addCleanup(client.join) + return client + + def test_returned_uuid_matches_the_wire_event_uuid(self): + client = self._client() + with mock.patch("posthog.consumer.batch_post") as mock_post: + returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") + client.flush() + + batch = mock_post.call_args.kwargs["batch"] + self.assertEqual(batch[0]["uuid"], returned_uuid) + + def test_supplied_uuid_is_preserved_end_to_end(self): + client = self._client() + supplied_uuid = str(uuid.uuid4()) + with mock.patch("posthog.consumer.batch_post") as mock_post: + returned_uuid = client.capture_ai( + "$ai_generation", distinct_id="d", uuid=supplied_uuid + ) + client.flush() + + self.assertEqual(returned_uuid, supplied_uuid) + batch = mock_post.call_args.kwargs["batch"] + self.assertEqual(batch[0]["uuid"], supplied_uuid) + + def test_returned_uuid_reflects_before_send_replacement(self): + replacement_uuid = str(uuid.uuid4()) + + def replace_uuid(event): + event["uuid"] = replacement_uuid + return event + + client = self._client(before_send=replace_uuid) + with mock.patch("posthog.consumer.batch_post") as mock_post: + returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") + client.flush() + + self.assertEqual(returned_uuid, replacement_uuid) + batch = mock_post.call_args.kwargs["batch"] + self.assertEqual(batch[0]["uuid"], replacement_uuid) + + def test_returned_uuid_is_regenerated_when_before_send_removes_it(self): + def drop_uuid(event): + del event["uuid"] + return event + + client = self._client(before_send=drop_uuid) + with mock.patch("posthog.consumer.batch_post") as mock_post: + returned_uuid = client.capture_ai("$ai_generation", distinct_id="d") + client.flush() + + self.assertIsNotNone(returned_uuid) + batch = mock_post.call_args.kwargs["batch"] + self.assertEqual(batch[0]["uuid"], returned_uuid) + + +class TestCaptureAiPrivacyMode(unittest.TestCase): + """Privacy mode always wins over `enable_full_ai_capture`.""" + + def test_privacy_mode_strips_content_despite_full_ai_capture(self): + client = Client( + TEST_API_KEY, + send=False, + enable_full_ai_capture=True, + privacy_mode=True, + ) + self.addCleanup(client.join) + payload = {"role": "user", "content": "sensitive prompt"} + + sanitized = with_privacy_mode( + client, False, finalize_ai_content(payload, ph_client=client) + ) + + self.assertIsNone(sanitized) + + if __name__ == "__main__": unittest.main() diff --git a/posthog/test/test_before_send.py b/posthog/test/test_before_send.py index a0a24739..56bc1030 100644 --- a/posthog/test/test_before_send.py +++ b/posthog/test/test_before_send.py @@ -67,6 +67,53 @@ def my_before_send(event): self.assertEqual(len(processed_events), 1) self.assertEqual(processed_events[0]["event"], "test_event") + def test_before_send_callback_replacing_uuid_changes_the_returned_uuid(self): + """capture()'s return value must match the uuid on the wire event.""" + replacement_uuid = "12345678-1234-5678-1234-567812345678" + + def replace_uuid(event): + event["uuid"] = replacement_uuid + return event + + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client( + FAKE_TEST_API_KEY, + on_error=self.set_fail, + before_send=replace_uuid, + sync_mode=True, + ) + msg_uuid = client.capture("test_event", distinct_id="user1") + + self.assertEqual(msg_uuid, replacement_uuid) + + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + enqueued_msg = batch_data[0] + self.assertEqual(enqueued_msg["uuid"], replacement_uuid) + + def test_before_send_callback_removing_uuid_regenerates_it(self): + """If before_send drops the uuid, a fresh one is generated and returned.""" + + def remove_uuid(event): + del event["uuid"] + return event + + with mock.patch("posthog.client.batch_post") as mock_post: + client = Client( + FAKE_TEST_API_KEY, + on_error=self.set_fail, + before_send=remove_uuid, + sync_mode=True, + ) + msg_uuid = client.capture("test_event", distinct_id="user1") + + self.assertIsNotNone(msg_uuid) + + mock_post.assert_called_once() + batch_data = mock_post.call_args[1]["batch"] + enqueued_msg = batch_data[0] + self.assertEqual(enqueued_msg["uuid"], msg_uuid) + def test_before_send_callback_drops_event(self): """Test that before_send callback can drop events by returning None.""" diff --git a/posthog/test/test_client.py b/posthog/test/test_client.py index dbbb561f..4b07ec72 100644 --- a/posthog/test/test_client.py +++ b/posthog/test/test_client.py @@ -4610,7 +4610,7 @@ def test_v1_sync_gzip_flag_falls_back_to_gzip_compression(self): def test_v1_sync_ai_named_event_through_capture_uses_v1(self): # `capture()` never special-cases AI events: an `$ai_*`-named event # follows `capture_mode` and rides the v1 submitter like any analytics - # event. Only `_capture_ai()` reaches the AI lane. + # event. Only `capture_ai()` reaches the AI lane. with ( mock.patch("posthog.client.batch_post") as mock_post, mock.patch("posthog.client._send_v1_batch") as mock_v1, diff --git a/posthog/test/test_client_fork.py b/posthog/test/test_client_fork.py index 16a64f7b..442322eb 100644 --- a/posthog/test/test_client_fork.py +++ b/posthog/test/test_client_fork.py @@ -238,7 +238,7 @@ def test_reinit_after_fork_normalizes_partially_closed_join_state(self): self.assertIsNone(client.poller) mock_poller.assert_not_called() self.assertIsNone(client.capture("analytics", distinct_id="distinct_id")) - self.assertIsNone(client._capture_ai("ai", distinct_id="distinct_id")) + self.assertIsNone(client.capture_ai("ai", distinct_id="distinct_id")) @unittest.skipUnless( diff --git a/references/public_api_snapshot.txt b/references/public_api_snapshot.txt index 34307b03..ea16c689 100644 --- a/references/public_api_snapshot.txt +++ b/references/public_api_snapshot.txt @@ -545,6 +545,7 @@ attribute posthog.client.Client.disabled = disabled or not self.api_key attribute posthog.client.Client.distinct_ids_feature_flags_reported = SizeLimitedDict(MAX_DICT_SIZE, set) attribute posthog.client.Client.enable_exception_autocapture = enable_exception_autocapture attribute posthog.client.Client.enable_exception_autocapture_rate_limiting = enable_exception_autocapture_rate_limiting +attribute posthog.client.Client.enable_full_ai_capture = enable_full_ai_capture is True or _use_ai_lane is True or _enable_multimodal_capture is True attribute posthog.client.Client.enable_local_evaluation = enable_local_evaluation attribute posthog.client.Client.exception_autocapture_bucket_size = exception_autocapture_bucket_size attribute posthog.client.Client.exception_autocapture_refill_interval_seconds = exception_autocapture_refill_interval_seconds @@ -625,6 +626,7 @@ attribute posthog.disable_geoip = True attribute posthog.disabled = False attribute posthog.enable_exception_autocapture = False attribute posthog.enable_exception_autocapture_rate_limiting = False +attribute posthog.enable_full_ai_capture = False attribute posthog.enable_local_evaluation = True attribute posthog.exception_autocapture_bucket_size = ExceptionCapture.DEFAULT_BUCKET_SIZE attribute posthog.exception_autocapture_refill_interval_seconds = ExceptionCapture.DEFAULT_REFILL_INTERVAL_SECONDS @@ -911,7 +913,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, _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, _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) @@ -1035,6 +1037,7 @@ function posthog.ai.utils.serialize_raw_usage(raw_usage: Any) -> Optional[Dict[s function posthog.ai.utils.with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any) function posthog.alias(previous_id: ID_TYPES, distinct_id: str, timestamp: Optional[datetime.datetime] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] function posthog.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +function posthog.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.capture_exception(exception: Optional[ExceptionArg] = None, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] function posthog.client.add_context_tags(properties) function posthog.client.get_identity_state(passed) -> tuple[str, bool] @@ -1240,6 +1243,7 @@ method posthog.bucketed_rate_limiter.BucketedRateLimiter.consume_rate_limit(key: method posthog.bucketed_rate_limiter.BucketedRateLimiter.stop() -> None method posthog.client.Client.alias(previous_id: ID_TYPES, distinct_id: Optional[str], timestamp: Optional[Union[datetime, str]] = None, uuid: Optional[str] = None, disable_geoip: Optional[bool] = None) -> Optional[str] method posthog.client.Client.capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] +method posthog.client.Client.capture_ai(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.capture_exception(exception: Optional[ExceptionArg], **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str] method posthog.client.Client.evaluate_flags(distinct_id: Optional[ID_TYPES] = None, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, disable_geoip: Optional[bool] = None, flag_keys: Optional[List[str]] = None, device_id: Optional[str] = None) -> FeatureFlagEvaluations method posthog.client.Client.feature_enabled(key: str, distinct_id: ID_TYPES, *, groups: Optional[Mapping[str, Union[str, int]]] = None, person_properties: Optional[Dict[str, Any]] = None, group_properties: Optional[Dict[str, Dict[str, Any]]] = None, only_evaluate_locally: bool = False, send_feature_flag_events: bool = True, disable_geoip: Optional[bool] = None, device_id: Optional[str] = None) -> Optional[bool] diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index d9453e00..f9e31f8e 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -303,7 +303,9 @@ def patched_post_v1( def health(): """Health check endpoint""" capabilities = ( - ["capture_v1", "encoding_gzip"] if is_v1() else ["capture_v0", "encoding_gzip"] + ["capture_v1", "capture_ai_v0", "encoding_gzip"] + if is_v1() + else ["capture_v0", "capture_ai_v0", "encoding_gzip"] ) return jsonify( { @@ -435,6 +437,60 @@ def capture(): return jsonify({"error": str(e)}), 500 +@app.route("/capture_ai", methods=["POST"]) +def capture_ai(): + """Capture a single AI event on the dedicated AI capture endpoint""" + try: + if not state.client: + return jsonify({"error": "SDK not initialized"}), 400 + + data = request.json or {} + + distinct_id = data.get("distinct_id") + event = data.get("event") + properties = data.get("properties") + timestamp = data.get("timestamp") + options = data.get("options") + supplied_uuid = data.get("uuid") + + if not distinct_id: + return jsonify({"error": "distinct_id is required"}), 400 + if not event: + return jsonify({"error": "event is required"}), 400 + + if options and is_v1(): + properties = dict(properties or {}) + option_to_property = { + "cookieless_mode": "$cookieless_mode", + "disable_skew_correction": "$ignore_sent_at", + "process_person_profile": "$process_person_profile", + "product_tour_id": "$product_tour_id", + } + for key, value in options.items(): + properties[option_to_property.get(key, "$" + key)] = value + + kwargs = {"distinct_id": distinct_id, "properties": properties} + if timestamp: + from dateutil.parser import parse + + kwargs["timestamp"] = parse(timestamp) + # Unlike /capture, forward a supplied uuid so it's echoed back to the caller. + if supplied_uuid: + kwargs["uuid"] = supplied_uuid + + uuid = state.client.capture_ai(event, **kwargs) + + state.increment_captured() + + logger.info(f"Captured AI event: {event} for {distinct_id}, uuid={uuid}") + + return jsonify({"success": True, "uuid": uuid}) + except Exception as e: + logger.exception("Error capturing AI event") + state.record_error(str(e)) + return jsonify({"error": str(e)}), 500 + + @app.route("/identify", methods=["POST"]) def identify(): """Identify a user"""