From c6abe0e2ff08161123de0bffb23f2b338eb36770 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 3 Sep 2026 09:06:10 +0200 Subject: [PATCH 1/3] feat: identify MCP and AI analytics libraries --- .sampo/changesets/virtuous-duchess-tursas.md | 5 +++ posthog/ai/utils.py | 10 ++++- posthog/capture_v1.py | 5 ++- posthog/client.py | 22 +++++++++- posthog/consumer.py | 4 ++ posthog/mcp/README.md | 5 +++ posthog/mcp/__init__.py | 5 +++ posthog/mcp/_lib_identity.py | 15 +++++++ posthog/mcp/constants.py | 1 + posthog/mcp/posthog_mcp.py | 2 + .../ai/claude_agent_sdk/test_processor.py | 4 ++ posthog/test/mcp/test_no_crash.py | 19 +++++++++ posthog/test/mcp/test_posthog_mcp.py | 39 +++++++++++++++++ posthog/test/test_ai_capture_lane.py | 42 ++++++++++++++++--- posthog/test/test_capture_v1.py | 17 ++++++++ 15 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 .sampo/changesets/virtuous-duchess-tursas.md create mode 100644 posthog/mcp/_lib_identity.py diff --git a/.sampo/changesets/virtuous-duchess-tursas.md b/.sampo/changesets/virtuous-duchess-tursas.md new file mode 100644 index 000000000..a6cac97ea --- /dev/null +++ b/.sampo/changesets/virtuous-duchess-tursas.md @@ -0,0 +1,5 @@ +--- +pypi/posthog: minor +--- + +Label MCP and AI analytics events with their integration libraries diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index c456c0b72..10734237c 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -10,6 +10,10 @@ from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage from posthog.client import Client as PostHogClient +from ..version import VERSION + + +_AI_LIB_PROPERTIES = {"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION} _TOKEN_PROPERTY_KEYS = frozenset( { @@ -63,7 +67,11 @@ def _ai_lane_enabled(ph_client) -> bool: def _capture_ai_event(ph_client, event: str, **kwargs): - """Capture a wrapper-emitted AI event, falling back to `capture()` for duck-typed clients without `capture_ai`.""" + """Capture a wrapper-emitted AI event with the PostHog AI library identity.""" + kwargs["properties"] = { + **_AI_LIB_PROPERTIES, + **(kwargs.get("properties") or {}), + } if _ai_lane_enabled(ph_client): capture_ai = getattr(ph_client, "capture_ai", None) if callable(capture_ai): diff --git a/posthog/capture_v1.py b/posthog/capture_v1.py index f9f356004..4cb3879b7 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_v1.py @@ -348,6 +348,7 @@ def _post_v1( request_id: str, compression: CaptureCompression = CaptureCompression.NONE, timeout: int = 15, + sdk_info: str = USER_AGENT, session: Optional["requests.Session"] = None, ) -> "requests.Response": """Perform a single ``POST /i/v1/analytics/events`` attempt. @@ -366,7 +367,7 @@ def _post_v1( "Content-Type": "application/json", "User-Agent": USER_AGENT, "Authorization": f"Bearer {api_key}", - _HEADER_SDK_INFO: USER_AGENT, + _HEADER_SDK_INFO: sdk_info, _HEADER_ATTEMPT: str(attempt), _HEADER_REQUEST_ID: request_id, _HEADER_REQUEST_TIMESTAMP: datetime.now(timezone.utc).isoformat(), @@ -470,6 +471,7 @@ def _send_v1_batch( timeout: int = 15, max_retries: int = 3, historical_migration: bool = False, + sdk_info: str = USER_AGENT, session: Optional["requests.Session"] = None, ) -> None: """Deliver ``batch`` to the v1 endpoint with partial retry. @@ -521,6 +523,7 @@ def _send_v1_batch( request_id=request_id, compression=compression, timeout=timeout, + sdk_info=sdk_info, session=session, ) except Exception as e: diff --git a/posthog/client.py b/posthog/client.py index 437eca2da..b10533c99 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -374,6 +374,7 @@ def __init__( max_msg_size, capture_mode, capture_compression, + sdk_info, eager_start, ): self.name = name @@ -391,6 +392,7 @@ def __init__( self.max_msg_size = max_msg_size self.capture_mode = capture_mode self.capture_compression = capture_compression + self.sdk_info = sdk_info self._max_queue_size = max_queue_size self._thread_count = thread_count self._eager_start = eager_start @@ -425,6 +427,7 @@ def _start_locked(self) -> None: max_msg_size=self.max_msg_size, capture_mode=self.capture_mode, capture_compression=self.capture_compression, + sdk_info=self.sdk_info, ) consumer._set_drain_signal(self._drain_signal) self.consumers.append(consumer) @@ -913,6 +916,9 @@ def __init__( # `/i/v1/analytics/events`). Resolved here so the env-var fallback is # applied once; V0 is the default and keeps upgrades transparent. self.capture_mode = _resolve_capture_mode(capture_mode) + self._library_id = "posthog-python" + self._library_version = VERSION + self._sdk_info = f"{self._library_id}/{self._library_version}" # v1-only request compression; falls back to the legacy `gzip` flag when # neither the kwarg nor POSTHOG_CAPTURE_COMPRESSION is set. self.capture_compression = _resolve_capture_compression( @@ -1033,6 +1039,7 @@ def __init__( max_retries=self.max_retries, timeout=timeout, historical_migration=historical_migration, + sdk_info=self._sdk_info, ) self._analytics_lane = _Lane( name="analytics", @@ -1068,6 +1075,16 @@ def __init__( self._warn_if_duplicate_async_client() + def _set_library_identity(self, library_id: str, library_version: str) -> None: + """Override the SDK identity stamped on events and capture-v1 requests.""" + self._library_id = library_id + self._library_version = library_version + self._sdk_info = f"{library_id}/{library_version}" + for lane in self._lanes: + lane.sdk_info = self._sdk_info + for consumer in lane.consumers: + consumer.sdk_info = self._sdk_info + @property def queue(self) -> Queue: """The analytics lane's queue (kept for backwards compatibility).""" @@ -2279,8 +2296,8 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None): if not msg.get("properties"): msg["properties"] = {} - msg["properties"]["$lib"] = "posthog-python" - msg["properties"]["$lib_version"] = VERSION + msg["properties"]["$lib"] = self._library_id + msg["properties"]["$lib_version"] = self._library_version if disable_geoip is None: disable_geoip = self.disable_geoip @@ -2356,6 +2373,7 @@ def send_sync() -> None: timeout=self.timeout, max_retries=self.max_retries, historical_migration=self.historical_migration, + sdk_info=self._sdk_info, ) return diff --git a/posthog/consumer.py b/posthog/consumer.py index bc41f4c15..6f5890fa4 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -10,6 +10,7 @@ from posthog.capture_v1 import _backoff, _send_v1_batch from posthog.request import ( EVENTS_ENDPOINT, + USER_AGENT, APIError, DatetimeSerializer, batch_post, @@ -116,6 +117,7 @@ def __init__( max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE, + sdk_info=USER_AGENT, ): """Create a consumer thread.""" Thread.__init__(self) @@ -132,6 +134,7 @@ def __init__( self.max_msg_size = max_msg_size self.capture_mode = capture_mode self.capture_compression = capture_compression + self.sdk_info = sdk_info self._drain_signal: Optional[_DrainSignal] = None self._drain_on_stop = False # It's important to set running in the constructor: if we are asked to @@ -300,6 +303,7 @@ def request(self, batch): timeout=self.timeout, max_retries=self.retries, historical_migration=self.historical_migration, + sdk_info=self.sdk_info, ) return self._send(batch, self.endpoint) diff --git a/posthog/mcp/README.md b/posthog/mcp/README.md index b358360ce..4ae3d5319 100644 --- a/posthog/mcp/README.md +++ b/posthog/mcp/README.md @@ -16,6 +16,11 @@ analytics = instrument(server, posthog) Install is just `pip install posthog`. `instrument()` needs the MCP SDK at runtime, but anyone wrapping a server already has it. +MCP analytics events report `$lib: "posthog-python-mcp"`. Because `$lib` is a +client-level identity, `instrument()` relabels every event sent by the client passed +to it. Use a client dedicated to MCP analytics if the application also captures +unrelated events. + ## Stateless / multi-pod servers A stateless MCP server issues no session id, so `$session_id` fragments across pods diff --git a/posthog/mcp/__init__.py b/posthog/mcp/__init__.py index 3c3c41385..3d9a58223 100644 --- a/posthog/mcp/__init__.py +++ b/posthog/mcp/__init__.py @@ -303,6 +303,11 @@ def instrument( "on SDK 2.x, or jlowin's fastmcp.FastMCP) or a low-level mcp.server.Server." ) + if client is not None: + from ._lib_identity import apply_mcp_lib_identity + + apply_mcp_lib_identity(client) + # Zero-config stateless minting: wrap the server's ASGI-app factories so a # stateless/multi-pod deployment keeps one $session_id + the client harness # across pods with no extra setup. No-op for stdio / low-level servers. diff --git a/posthog/mcp/_lib_identity.py b/posthog/mcp/_lib_identity.py new file mode 100644 index 000000000..0f83380b0 --- /dev/null +++ b/posthog/mcp/_lib_identity.py @@ -0,0 +1,15 @@ +"""Apply the MCP-specific identity to the underlying PostHog client.""" + +from __future__ import annotations + +from ..client import Client + +from .constants import POSTHOG_MCP_LIB_NAME +from .version import __version__ + + +def apply_mcp_lib_identity(client: Client) -> None: + """Relabel every event sent by ``client`` as coming from ``posthog.mcp``.""" + set_identity = getattr(client, "_set_library_identity", None) + if set_identity is not None: + set_identity(POSTHOG_MCP_LIB_NAME, __version__) diff --git a/posthog/mcp/constants.py b/posthog/mcp/constants.py index 427735e30..f999eae11 100644 --- a/posthog/mcp/constants.py +++ b/posthog/mcp/constants.py @@ -33,6 +33,7 @@ ) POSTHOG_MCP_ANALYTICS_SOURCE = "posthog_mcp_analytics" +POSTHOG_MCP_LIB_NAME = "posthog-python-mcp" class PostHogMCPAnalyticsEvent: diff --git a/posthog/mcp/posthog_mcp.py b/posthog/mcp/posthog_mcp.py index a616a88be..926fcf6f1 100644 --- a/posthog/mcp/posthog_mcp.py +++ b/posthog/mcp/posthog_mcp.py @@ -24,6 +24,7 @@ from ._event_types import MCPAnalyticsEventType from ._exceptions import capture_exception from ._instrumentation import drain_pending_sync, fire_and_forget +from ._lib_identity import apply_mcp_lib_identity from ._sink import McpCaptureOptions, McpEventSink from .tools import build_report_missing_descriptor from .types import ( @@ -51,6 +52,7 @@ def __init__( **kwargs: Any, ) -> None: super().__init__(api_key, **kwargs) + apply_mcp_lib_identity(self) self._mcp_sink = McpEventSink(self) self._missing_capability_tool_name = ( missing_capability_tool_name or _GET_MORE_TOOLS_NAME diff --git a/posthog/test/ai/claude_agent_sdk/test_processor.py b/posthog/test/ai/claude_agent_sdk/test_processor.py index 57cbd3c54..3dc776002 100644 --- a/posthog/test/ai/claude_agent_sdk/test_processor.py +++ b/posthog/test/ai/claude_agent_sdk/test_processor.py @@ -8,6 +8,8 @@ import pytest +from posthog.version import VERSION + try: from claude_agent_sdk.types import ( AssistantMessage, @@ -694,6 +696,8 @@ def test_default_properties_keep_existing_precedence(self, mock_client): ) assert mock_client.capture.call_args.kwargs["properties"] == { + "$ai_lib": "posthog-ai", + "$ai_lib_version": VERSION, "environment": "processor", "$ai_trace_id": "trace-id", } diff --git a/posthog/test/mcp/test_no_crash.py b/posthog/test/mcp/test_no_crash.py index 752ef3b13..60701f2a9 100644 --- a/posthog/test/mcp/test_no_crash.py +++ b/posthog/test/mcp/test_no_crash.py @@ -9,7 +9,9 @@ import pytest +from posthog.client import Client from posthog.mcp import instrument +from posthog.mcp.version import __version__ as MCP_VERSION from posthog.test.mcp._helpers import MCP_MAJOR, FakeClient @@ -59,6 +61,23 @@ def test_low_level_server_detected_on_installed_major(): assert compat.is_low_level_server(object()) is False +def test_instrument_relabels_the_host_client(): + from mcp.server.lowlevel import Server + + captured = [] + + def before_send(event): + captured.append(event) + return event + + client = Client("phc_test", send=False, before_send=before_send) + instrument(Server("probe-lib-identity"), client) + client.capture("after instrumentation") + + assert captured[0]["properties"]["$lib"] == "posthog-python-mcp" + assert captured[0]["properties"]["$lib_version"] == MCP_VERSION + + @pytest.mark.parametrize( ("installed", "should_warn"), [ diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index 292aa2f71..df0a279ad 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -1,6 +1,10 @@ """Tests for the PostHogMCP custom-dispatcher client (Milestone 3).""" +from unittest import mock + +from posthog.capture_mode import CaptureMode from posthog.mcp import PostHogMCP +from posthog.mcp.version import __version__ as MCP_VERSION from posthog.test.mcp._helpers import ( events_named as _events, flush_background as _flush, @@ -50,6 +54,41 @@ async def test_capture_tool_call_error_fans_out_exception(): assert exc and exc[0]["properties"]["$exception_list"][0]["value"] == "kaboom" +async def test_mcp_events_use_mcp_library_identity(): + captured = [] + + def before_send(event): + captured.append(event) + return event + + client = PostHogMCP( + "phc_test", + host="https://us.i.posthog.com", + send=False, + before_send=before_send, + ) + client.capture_tool_call("broken", is_error=True, error=RuntimeError("kaboom")) + await _flush() + + assert {event["event"] for event in captured} == {"$mcp_tool_call", "$exception"} + assert all( + event["properties"]["$lib"] == "posthog-python-mcp" + and event["properties"]["$lib_version"] == MCP_VERSION + for event in captured + ) + + +def test_mcp_library_identity_reaches_capture_v1_header(): + client = PostHogMCP("phc_test", sync_mode=True, capture_mode=CaptureMode.V1) + with mock.patch("posthog.client._send_v1_batch") as send: + client.capture("$mcp_custom") + + assert send.call_args.kwargs["sdk_info"] == f"posthog-python-mcp/{MCP_VERSION}" + event = send.call_args.args[2][0] + assert event["properties"]["$lib"] == "posthog-python-mcp" + assert event["properties"]["$lib_version"] == MCP_VERSION + + async def test_capture_initialize_and_tools_list(): client, captured = make_client() client.capture_initialize( diff --git a/posthog/test/test_ai_capture_lane.py b/posthog/test/test_ai_capture_lane.py index a11379be8..3ab2f9f87 100644 --- a/posthog/test/test_ai_capture_lane.py +++ b/posthog/test/test_ai_capture_lane.py @@ -10,6 +10,7 @@ from posthog.client import Client from posthog.consumer import AI_MAX_MSG_SIZE, MAX_MSG_SIZE from posthog.request import AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT +from posthog.version import VERSION from posthog.test.test_utils import TEST_API_KEY @@ -359,12 +360,31 @@ def test_default_keeps_capture_path(self): self.assertEqual(client._ai_lane.consumers, []) client.join() + def test_adds_ai_library_identity_and_preserves_provider_and_model(self): + client = mock.Mock() + _capture_ai_event( + client, + "$ai_generation", + distinct_id="d", + properties={"$ai_provider": "openai", "$ai_model": "gpt-4o"}, + ) + + properties = client.capture.call_args.kwargs["properties"] + self.assertEqual(properties["$ai_lib"], "posthog-ai") + self.assertEqual(properties["$ai_lib_version"], VERSION) + self.assertEqual(properties["$ai_provider"], "openai") + self.assertEqual(properties["$ai_model"], "gpt-4o") + def test_default_mock_clients_keep_seeing_capture(self): # Downstream test suites pass Mock clients into the wrappers; without # the opt-in they must keep seeing plain `capture()` calls. 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.assert_called_once_with( + event="$ai_generation", + distinct_id="d", + properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, + ) client.capture_ai.assert_not_called() def test_opted_in_prefers_capture_ai(self): @@ -372,7 +392,9 @@ def test_opted_in_prefers_capture_ai(self): client.enable_full_ai_capture = True _capture_ai_event(client, "$ai_generation", distinct_id="d") client.capture_ai.assert_called_once_with( - event="$ai_generation", distinct_id="d" + event="$ai_generation", + distinct_id="d", + properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, ) client.capture.assert_not_called() @@ -380,14 +402,20 @@ def test_opted_in_duck_typed_client_without_method_falls_back(self): 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") + client.capture.assert_called_once_with( + event="$ai_generation", + distinct_id="d", + properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, + ) def test_client_multimodal_flag_prefers_capture_ai(self): 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( - event="$ai_generation", distinct_id="d" + event="$ai_generation", + distinct_id="d", + properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, ) client.capture.assert_not_called() @@ -395,7 +423,11 @@ def test_client_multimodal_flag_off_keeps_capture(self): 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") + client.capture.assert_called_once_with( + event="$ai_generation", + distinct_id="d", + properties={"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION}, + ) class TestLanesRefuseWorkAfterShutdown(unittest.TestCase): diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_v1.py index 9180ee0e6..b82e3dd4e 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_v1.py @@ -26,6 +26,7 @@ _coerce_bool, _coerce_str, ) +from posthog.request import USER_AGENT class _FakeResponse: @@ -81,6 +82,7 @@ def __call__( request_id, compression=CaptureCompression.NONE, timeout=15, + sdk_info=USER_AGENT, session=None, ): self.calls.append( @@ -88,6 +90,7 @@ def __call__( "attempt": attempt, "request_id": request_id, "compression": compression, + "sdk_info": sdk_info, "created_at": batch_body["created_at"], "uuids": [e["uuid"] for e in batch_body["batch"]], } @@ -418,6 +421,12 @@ def test_required_headers_present(self) -> None: request_timestamp = datetime.fromisoformat(headers[_HEADER_REQUEST_TIMESTAMP]) self.assertEqual(request_timestamp.utcoffset(), timedelta(0)) + def test_custom_sdk_info_header(self) -> None: + headers = self._post( + _results_response({}), sdk_info="posthog-python-mcp/0.3.0" + )["headers"] + self.assertEqual(headers[_HEADER_SDK_INFO], "posthog-python-mcp/0.3.0") + def test_no_api_key_in_body(self) -> None: # v1 authenticates via the Bearer header; the key must not leak into the body. data = self._post(_results_response({}))["data"] @@ -534,6 +543,14 @@ def test_all_ok_sends_once(self) -> None: self.assertEqual(len(stub.calls), 1) self.sleep.assert_not_called() + def test_custom_sdk_info_is_forwarded(self) -> None: + stub = self._run( + [_msg("u-1")], + [_results_response({"u-1": "ok"})], + sdk_info="posthog-python-mcp/0.3.0", + ) + self.assertEqual(stub.calls[0]["sdk_info"], "posthog-python-mcp/0.3.0") + def test_absent_uuid_treated_as_accepted(self) -> None: # Empty results map: the event is neither retried nor errored. stub = self._run([_msg("u-1")], [_results_response({})]) From f20037eebfb2c3bd40d2f00e22a77f1a93c173cd Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 3 Sep 2026 09:14:35 +0200 Subject: [PATCH 2/3] fix: keep library identity plumbing private --- posthog/ai/utils.py | 7 +++++-- posthog/client.py | 4 ++-- posthog/consumer.py | 7 +++---- sdk_compliance_adapter/adapter.py | 4 +++- 4 files changed, 13 insertions(+), 9 deletions(-) diff --git a/posthog/ai/utils.py b/posthog/ai/utils.py index 10734237c..4f88e7ee7 100644 --- a/posthog/ai/utils.py +++ b/posthog/ai/utils.py @@ -10,10 +10,13 @@ from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage from posthog.client import Client as PostHogClient -from ..version import VERSION +from ..version import VERSION as _POSTHOG_VERSION -_AI_LIB_PROPERTIES = {"$ai_lib": "posthog-ai", "$ai_lib_version": VERSION} +_AI_LIB_PROPERTIES = { + "$ai_lib": "posthog-ai", + "$ai_lib_version": _POSTHOG_VERSION, +} _TOKEN_PROPERTY_KEYS = frozenset( { diff --git a/posthog/client.py b/posthog/client.py index b10533c99..05b4b2f2e 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -427,8 +427,8 @@ def _start_locked(self) -> None: max_msg_size=self.max_msg_size, capture_mode=self.capture_mode, capture_compression=self.capture_compression, - sdk_info=self.sdk_info, ) + consumer._sdk_info = self.sdk_info consumer._set_drain_signal(self._drain_signal) self.consumers.append(consumer) @@ -1083,7 +1083,7 @@ def _set_library_identity(self, library_id: str, library_version: str) -> None: for lane in self._lanes: lane.sdk_info = self._sdk_info for consumer in lane.consumers: - consumer.sdk_info = self._sdk_info + consumer._sdk_info = self._sdk_info @property def queue(self) -> Queue: diff --git a/posthog/consumer.py b/posthog/consumer.py index 6f5890fa4..4f4bcaa17 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -10,7 +10,7 @@ from posthog.capture_v1 import _backoff, _send_v1_batch from posthog.request import ( EVENTS_ENDPOINT, - USER_AGENT, + USER_AGENT as _USER_AGENT, APIError, DatetimeSerializer, batch_post, @@ -117,7 +117,6 @@ def __init__( max_msg_size=MAX_MSG_SIZE, capture_mode=CaptureMode.V0, capture_compression=CaptureCompression.NONE, - sdk_info=USER_AGENT, ): """Create a consumer thread.""" Thread.__init__(self) @@ -134,7 +133,7 @@ def __init__( self.max_msg_size = max_msg_size self.capture_mode = capture_mode self.capture_compression = capture_compression - self.sdk_info = sdk_info + self._sdk_info = _USER_AGENT self._drain_signal: Optional[_DrainSignal] = None self._drain_on_stop = False # It's important to set running in the constructor: if we are asked to @@ -303,7 +302,7 @@ def request(self, batch): timeout=self.timeout, max_retries=self.retries, historical_migration=self.historical_migration, - sdk_info=self.sdk_info, + sdk_info=self._sdk_info, ) return self._send(batch, self.endpoint) diff --git a/sdk_compliance_adapter/adapter.py b/sdk_compliance_adapter/adapter.py index f9e31f8e5..b9c0de33d 100644 --- a/sdk_compliance_adapter/adapter.py +++ b/sdk_compliance_adapter/adapter.py @@ -16,7 +16,7 @@ from posthog import Client from posthog.capture_compression import CaptureCompression from posthog.capture_v1 import _post_v1 as original_post_v1 -from posthog.request import EVENTS_ENDPOINT +from posthog.request import EVENTS_ENDPOINT, USER_AGENT from posthog.request import batch_post as original_batch_post from posthog.version import VERSION @@ -236,6 +236,7 @@ def patched_post_v1( request_id: str, compression: CaptureCompression = CaptureCompression.NONE, timeout: int = 15, + sdk_info: str = USER_AGENT, session: Any = None, ): """Patched version of _post_v1 that records requests for /state assertions. @@ -253,6 +254,7 @@ def patched_post_v1( request_id=request_id, compression=compression, timeout=timeout, + sdk_info=sdk_info, session=session, ) except Exception as e: From 3263d3c7d432f90ad0b263319edacf246a9d48f3 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Thu, 3 Sep 2026 14:04:24 +0200 Subject: [PATCH 3/3] fix: propagate MCP identity to all requests --- .sampo/changesets/virtuous-duchess-tursas.md | 2 +- posthog/capture_v1.py | 2 +- posthog/client.py | 23 +++++++- posthog/consumer.py | 5 ++ posthog/request.py | 37 +++++++++++- posthog/test/mcp/test_posthog_mcp.py | 59 ++++++++++++++++++++ posthog/test/test_capture_v1.py | 3 +- 7 files changed, 122 insertions(+), 9 deletions(-) diff --git a/.sampo/changesets/virtuous-duchess-tursas.md b/.sampo/changesets/virtuous-duchess-tursas.md index a6cac97ea..6bde09fe7 100644 --- a/.sampo/changesets/virtuous-duchess-tursas.md +++ b/.sampo/changesets/virtuous-duchess-tursas.md @@ -2,4 +2,4 @@ pypi/posthog: minor --- -Label MCP and AI analytics events with their integration libraries +Label MCP events and requests as `posthog-python-mcp`, and AI events as `posthog-ai` diff --git a/posthog/capture_v1.py b/posthog/capture_v1.py index 4cb3879b7..478175484 100644 --- a/posthog/capture_v1.py +++ b/posthog/capture_v1.py @@ -365,7 +365,7 @@ def _post_v1( data = json.dumps(batch_body, cls=DatetimeSerializer) headers = { "Content-Type": "application/json", - "User-Agent": USER_AGENT, + "User-Agent": sdk_info, "Authorization": f"Bearer {api_key}", _HEADER_SDK_INFO: sdk_info, _HEADER_ATTEMPT: str(attempt), diff --git a/posthog/client.py b/posthog/client.py index 05b4b2f2e..59ccb9dca 100644 --- a/posthog/client.py +++ b/posthog/client.py @@ -78,10 +78,13 @@ from posthog.request import ( AI_EVENTS_ENDPOINT, EVENTS_ENDPOINT, + USER_AGENT as _USER_AGENT, APIError, QuotaLimitError, RequestsConnectionError, RequestsTimeout, + _get as _get_with_identity, + _remote_config as _remote_config_with_identity, batch_post, determine_server_host, flags, @@ -1076,7 +1079,7 @@ def __init__( self._warn_if_duplicate_async_client() def _set_library_identity(self, library_id: str, library_version: str) -> None: - """Override the SDK identity stamped on events and capture-v1 requests.""" + """Override the SDK identity stamped on events and outbound requests.""" self._library_id = library_id self._library_version = library_version self._sdk_info = f"{library_id}/{library_version}" @@ -1085,6 +1088,9 @@ def _set_library_identity(self, library_id: str, library_version: str) -> None: for consumer in lane.consumers: consumer._sdk_info = self._sdk_info + def _request_identity_kwargs(self) -> Dict[str, str]: + return {"_user_agent": self._sdk_info} if self._sdk_info != _USER_AGENT else {} + @property def queue(self) -> Queue: """The analytics lane's queue (kept for backwards compatibility).""" @@ -1498,6 +1504,8 @@ def _get_flags_decision( if flag_keys_to_evaluate: request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate + if self._sdk_info != _USER_AGENT: + request_data["_user_agent"] = self._sdk_info resp_data = flags( self.api_key, @@ -2385,6 +2393,7 @@ def send_sync() -> None: batch=[msg], historical_migration=self.historical_migration, path=lane.endpoint, + **self._request_identity_kwargs(), ) if lane.run_sync_if_open(send_sync): @@ -2943,12 +2952,14 @@ def _fetch_feature_flags_from_api(self): cache_data_to_store: Optional[FlagDefinitionCacheData] = None try: - response = get( + request_get = _get_with_identity if self._request_identity_kwargs() else get + response = request_get( personal_api_key, f"/flags/definitions?token={self.api_key}&send_cohorts", self.host, timeout=10, etag=request_etag, + **self._request_identity_kwargs(), ) with self._flag_definition_publication_lock: @@ -3883,12 +3894,18 @@ def get_remote_config_payload(self, key: str): return None try: - return remote_config( + request_remote_config = ( + _remote_config_with_identity + if self._request_identity_kwargs() + else remote_config + ) + return request_remote_config( self.personal_api_key, self.api_key, self.host, key, timeout=self.feature_flags_request_timeout_seconds, + **self._request_identity_kwargs(), ) except Exception as e: self.log.exception( diff --git a/posthog/consumer.py b/posthog/consumer.py index 4f4bcaa17..c604b9756 100644 --- a/posthog/consumer.py +++ b/posthog/consumer.py @@ -335,6 +335,11 @@ def is_retryable(exc): batch=batch, historical_migration=self.historical_migration, path=path, + **( + {"_user_agent": self._sdk_info} + if self._sdk_info != _USER_AGENT + else {} + ), ) return except Exception as e: diff --git a/posthog/request.py b/posthog/request.py index 14a75d93d..76df1fdc9 100644 --- a/posthog/request.py +++ b/posthog/request.py @@ -223,6 +223,7 @@ def post( ) -> requests.Response: """Post the `kwargs` to the API""" log = logging.getLogger("posthog") + user_agent = kwargs.pop("_user_agent", USER_AGENT) body = kwargs body["sent_at"] = datetime.now(tz=timezone.utc).isoformat() trimmed_host = remove_trailing_slash(normalize_host(host)) @@ -235,7 +236,7 @@ def post( json.dumps({**body, "api_key": "[redacted]"}, cls=DatetimeSerializer), url, ) - headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT} + headers = {"Content-Type": "application/json", "User-Agent": user_agent} if gzip: try: buf = BytesIO() @@ -319,6 +320,7 @@ def flags( **kwargs, ) -> Any: """Post the kwargs to the flags API endpoint with bounded transient retries.""" + user_agent = kwargs.pop("_user_agent", USER_AGENT) retries = max(0, max_retries) failed_attempt = 0 @@ -331,6 +333,7 @@ def flags( gzip, timeout, session=_get_flags_session(), + _user_agent=user_agent, **kwargs, ) return _process_response( @@ -357,11 +360,24 @@ def remote_config( timeout: int = 15, ) -> Any: """Get remote config flag value from remote_config API endpoint""" - response = get( + return _remote_config(personal_api_key, project_api_key, host, key, timeout) + + +def _remote_config( + personal_api_key: str, + project_api_key: str, + host: Optional[str] = None, + key: str = "", + timeout: int = 15, + *, + _user_agent: str = USER_AGENT, +) -> Any: + response = _get( personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}", host, timeout, + _user_agent=_user_agent, ) return response.data @@ -399,10 +415,25 @@ def get( - not_modified=True and data=None if server returns 304 - not_modified=False and data=response if server returns 200 """ + return _get(api_key, url, host, timeout, etag) + + +def _get( + api_key: str, + url: str, + host: Optional[str] = None, + timeout: Optional[int] = None, + etag: Optional[str] = None, + *, + _user_agent: str = USER_AGENT, +) -> GetResponse: log = logging.getLogger("posthog") trimmed_host = remove_trailing_slash(normalize_host(host)) full_url = trimmed_host + url - headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT} + headers = { + "Authorization": "Bearer %s" % api_key, + "User-Agent": _user_agent, + } if etag: headers["If-None-Match"] = etag diff --git a/posthog/test/mcp/test_posthog_mcp.py b/posthog/test/mcp/test_posthog_mcp.py index df0a279ad..177c4e0ad 100644 --- a/posthog/test/mcp/test_posthog_mcp.py +++ b/posthog/test/mcp/test_posthog_mcp.py @@ -78,6 +78,18 @@ def before_send(event): ) +def test_mcp_library_identity_reaches_capture_v0_header(): + response = mock.Mock(status_code=200) + client = PostHogMCP("phc_test", sync_mode=True) + + with mock.patch("posthog.request._session.post", return_value=response) as post: + client.capture("$mcp_custom") + + assert post.call_args.kwargs["headers"]["User-Agent"] == ( + f"posthog-python-mcp/{MCP_VERSION}" + ) + + def test_mcp_library_identity_reaches_capture_v1_header(): client = PostHogMCP("phc_test", sync_mode=True, capture_mode=CaptureMode.V1) with mock.patch("posthog.client._send_v1_batch") as send: @@ -89,6 +101,53 @@ def test_mcp_library_identity_reaches_capture_v1_header(): assert event["properties"]["$lib_version"] == MCP_VERSION +def test_mcp_library_identity_reaches_feature_flag_requests(): + response = mock.Mock(status_code=200) + response.json.return_value = {"flags": {}} + client = PostHogMCP("phc_test", send=False) + + with mock.patch( + "posthog.request._flags_session.post", return_value=response + ) as post: + client.evaluate_flags("user_1") + + assert post.call_args.kwargs["headers"]["User-Agent"] == ( + f"posthog-python-mcp/{MCP_VERSION}" + ) + + +def test_mcp_library_identity_reaches_feature_flag_definition_requests(): + response = mock.Mock(status_code=200, headers={}) + response.json.return_value = {"flags": [], "group_type_mapping": {}, "cohorts": {}} + client = PostHogMCP( + "phc_test", + secret_key="phs_test", + send=False, + enable_local_evaluation=False, + ) + + with mock.patch("posthog.request._session.get", return_value=response) as get: + client.load_feature_flags() + + assert get.call_args.kwargs["headers"]["User-Agent"] == ( + f"posthog-python-mcp/{MCP_VERSION}" + ) + client.shutdown() + + +def test_mcp_library_identity_reaches_remote_config_requests(): + response = mock.Mock(status_code=200, headers={}) + response.json.return_value = "payload" + client = PostHogMCP("phc_test", secret_key="phs_test", send=False) + + with mock.patch("posthog.request._session.get", return_value=response) as get: + assert client.get_remote_config_payload("flag-key") == "payload" + + assert get.call_args.kwargs["headers"]["User-Agent"] == ( + f"posthog-python-mcp/{MCP_VERSION}" + ) + + async def test_capture_initialize_and_tools_list(): client, captured = make_client() client.capture_initialize( diff --git a/posthog/test/test_capture_v1.py b/posthog/test/test_capture_v1.py index b82e3dd4e..f4809d501 100644 --- a/posthog/test/test_capture_v1.py +++ b/posthog/test/test_capture_v1.py @@ -421,11 +421,12 @@ def test_required_headers_present(self) -> None: request_timestamp = datetime.fromisoformat(headers[_HEADER_REQUEST_TIMESTAMP]) self.assertEqual(request_timestamp.utcoffset(), timedelta(0)) - def test_custom_sdk_info_header(self) -> None: + def test_custom_sdk_info_headers(self) -> None: headers = self._post( _results_response({}), sdk_info="posthog-python-mcp/0.3.0" )["headers"] self.assertEqual(headers[_HEADER_SDK_INFO], "posthog-python-mcp/0.3.0") + self.assertEqual(headers["User-Agent"], "posthog-python-mcp/0.3.0") def test_no_api_key_in_body(self) -> None: # v1 authenticates via the Bearer header; the key must not leak into the body.