Skip to content

Commit 4ab555d

Browse files
authored
feat: identify MCP and AI analytics events by library (#917)
* feat: identify MCP and AI analytics libraries * fix: keep library identity plumbing private * fix: propagate MCP identity to all requests
1 parent 73662b2 commit 4ab555d

17 files changed

Lines changed: 310 additions & 16 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
pypi/posthog: minor
3+
---
4+
5+
Label MCP events and requests as `posthog-python-mcp`, and AI events as `posthog-ai`

‎posthog/ai/utils.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,13 @@
1010
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
1111
from posthog.client import Client as PostHogClient
1212

13+
from ..version import VERSION as _POSTHOG_VERSION
14+
15+
16+
_AI_LIB_PROPERTIES = {
17+
"$ai_lib": "posthog-ai",
18+
"$ai_lib_version": _POSTHOG_VERSION,
19+
}
1320

1421
_TOKEN_PROPERTY_KEYS = frozenset(
1522
{
@@ -63,7 +70,11 @@ def _ai_lane_enabled(ph_client) -> bool:
6370

6471

6572
def _capture_ai_event(ph_client, event: str, **kwargs):
66-
"""Capture a wrapper-emitted AI event, falling back to `capture()` for duck-typed clients without `capture_ai`."""
73+
"""Capture a wrapper-emitted AI event with the PostHog AI library identity."""
74+
kwargs["properties"] = {
75+
**_AI_LIB_PROPERTIES,
76+
**(kwargs.get("properties") or {}),
77+
}
6778
if _ai_lane_enabled(ph_client):
6879
capture_ai = getattr(ph_client, "capture_ai", None)
6980
if callable(capture_ai):

‎posthog/capture_v1.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,7 @@ def _post_v1(
348348
request_id: str,
349349
compression: CaptureCompression = CaptureCompression.NONE,
350350
timeout: int = 15,
351+
sdk_info: str = USER_AGENT,
351352
session: Optional["requests.Session"] = None,
352353
) -> "requests.Response":
353354
"""Perform a single ``POST /i/v1/analytics/events`` attempt.
@@ -364,9 +365,9 @@ def _post_v1(
364365
data = json.dumps(batch_body, cls=DatetimeSerializer)
365366
headers = {
366367
"Content-Type": "application/json",
367-
"User-Agent": USER_AGENT,
368+
"User-Agent": sdk_info,
368369
"Authorization": f"Bearer {api_key}",
369-
_HEADER_SDK_INFO: USER_AGENT,
370+
_HEADER_SDK_INFO: sdk_info,
370371
_HEADER_ATTEMPT: str(attempt),
371372
_HEADER_REQUEST_ID: request_id,
372373
_HEADER_REQUEST_TIMESTAMP: datetime.now(timezone.utc).isoformat(),
@@ -470,6 +471,7 @@ def _send_v1_batch(
470471
timeout: int = 15,
471472
max_retries: int = 3,
472473
historical_migration: bool = False,
474+
sdk_info: str = USER_AGENT,
473475
session: Optional["requests.Session"] = None,
474476
) -> None:
475477
"""Deliver ``batch`` to the v1 endpoint with partial retry.
@@ -521,6 +523,7 @@ def _send_v1_batch(
521523
request_id=request_id,
522524
compression=compression,
523525
timeout=timeout,
526+
sdk_info=sdk_info,
524527
session=session,
525528
)
526529
except Exception as e:

‎posthog/client.py‎

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,13 @@
7878
from posthog.request import (
7979
AI_EVENTS_ENDPOINT,
8080
EVENTS_ENDPOINT,
81+
USER_AGENT as _USER_AGENT,
8182
APIError,
8283
QuotaLimitError,
8384
RequestsConnectionError,
8485
RequestsTimeout,
86+
_get as _get_with_identity,
87+
_remote_config as _remote_config_with_identity,
8588
batch_post,
8689
determine_server_host,
8790
flags,
@@ -374,6 +377,7 @@ def __init__(
374377
max_msg_size,
375378
capture_mode,
376379
capture_compression,
380+
sdk_info,
377381
eager_start,
378382
):
379383
self.name = name
@@ -391,6 +395,7 @@ def __init__(
391395
self.max_msg_size = max_msg_size
392396
self.capture_mode = capture_mode
393397
self.capture_compression = capture_compression
398+
self.sdk_info = sdk_info
394399
self._max_queue_size = max_queue_size
395400
self._thread_count = thread_count
396401
self._eager_start = eager_start
@@ -426,6 +431,7 @@ def _start_locked(self) -> None:
426431
capture_mode=self.capture_mode,
427432
capture_compression=self.capture_compression,
428433
)
434+
consumer._sdk_info = self.sdk_info
429435
consumer._set_drain_signal(self._drain_signal)
430436
self.consumers.append(consumer)
431437

@@ -913,6 +919,9 @@ def __init__(
913919
# `/i/v1/analytics/events`). Resolved here so the env-var fallback is
914920
# applied once; V0 is the default and keeps upgrades transparent.
915921
self.capture_mode = _resolve_capture_mode(capture_mode)
922+
self._library_id = "posthog-python"
923+
self._library_version = VERSION
924+
self._sdk_info = f"{self._library_id}/{self._library_version}"
916925
# v1-only request compression; falls back to the legacy `gzip` flag when
917926
# neither the kwarg nor POSTHOG_CAPTURE_COMPRESSION is set.
918927
self.capture_compression = _resolve_capture_compression(
@@ -1033,6 +1042,7 @@ def __init__(
10331042
max_retries=self.max_retries,
10341043
timeout=timeout,
10351044
historical_migration=historical_migration,
1045+
sdk_info=self._sdk_info,
10361046
)
10371047
self._analytics_lane = _Lane(
10381048
name="analytics",
@@ -1068,6 +1078,19 @@ def __init__(
10681078

10691079
self._warn_if_duplicate_async_client()
10701080

1081+
def _set_library_identity(self, library_id: str, library_version: str) -> None:
1082+
"""Override the SDK identity stamped on events and outbound requests."""
1083+
self._library_id = library_id
1084+
self._library_version = library_version
1085+
self._sdk_info = f"{library_id}/{library_version}"
1086+
for lane in self._lanes:
1087+
lane.sdk_info = self._sdk_info
1088+
for consumer in lane.consumers:
1089+
consumer._sdk_info = self._sdk_info
1090+
1091+
def _request_identity_kwargs(self) -> Dict[str, str]:
1092+
return {"_user_agent": self._sdk_info} if self._sdk_info != _USER_AGENT else {}
1093+
10711094
@property
10721095
def queue(self) -> Queue:
10731096
"""The analytics lane's queue (kept for backwards compatibility)."""
@@ -1481,6 +1504,8 @@ def _get_flags_decision(
14811504

14821505
if flag_keys_to_evaluate:
14831506
request_data["flag_keys_to_evaluate"] = flag_keys_to_evaluate
1507+
if self._sdk_info != _USER_AGENT:
1508+
request_data["_user_agent"] = self._sdk_info
14841509

14851510
resp_data = flags(
14861511
self.api_key,
@@ -2279,8 +2304,8 @@ def _enqueue(self, msg, disable_geoip, lane=None, property_allowlist=None):
22792304

22802305
if not msg.get("properties"):
22812306
msg["properties"] = {}
2282-
msg["properties"]["$lib"] = "posthog-python"
2283-
msg["properties"]["$lib_version"] = VERSION
2307+
msg["properties"]["$lib"] = self._library_id
2308+
msg["properties"]["$lib_version"] = self._library_version
22842309

22852310
if disable_geoip is None:
22862311
disable_geoip = self.disable_geoip
@@ -2356,6 +2381,7 @@ def send_sync() -> None:
23562381
timeout=self.timeout,
23572382
max_retries=self.max_retries,
23582383
historical_migration=self.historical_migration,
2384+
sdk_info=self._sdk_info,
23592385
)
23602386
return
23612387

@@ -2367,6 +2393,7 @@ def send_sync() -> None:
23672393
batch=[msg],
23682394
historical_migration=self.historical_migration,
23692395
path=lane.endpoint,
2396+
**self._request_identity_kwargs(),
23702397
)
23712398

23722399
if lane.run_sync_if_open(send_sync):
@@ -2925,12 +2952,14 @@ def _fetch_feature_flags_from_api(self):
29252952

29262953
cache_data_to_store: Optional[FlagDefinitionCacheData] = None
29272954
try:
2928-
response = get(
2955+
request_get = _get_with_identity if self._request_identity_kwargs() else get
2956+
response = request_get(
29292957
personal_api_key,
29302958
f"/flags/definitions?token={self.api_key}&send_cohorts",
29312959
self.host,
29322960
timeout=10,
29332961
etag=request_etag,
2962+
**self._request_identity_kwargs(),
29342963
)
29352964

29362965
with self._flag_definition_publication_lock:
@@ -3865,12 +3894,18 @@ def get_remote_config_payload(self, key: str):
38653894
return None
38663895

38673896
try:
3868-
return remote_config(
3897+
request_remote_config = (
3898+
_remote_config_with_identity
3899+
if self._request_identity_kwargs()
3900+
else remote_config
3901+
)
3902+
return request_remote_config(
38693903
self.personal_api_key,
38703904
self.api_key,
38713905
self.host,
38723906
key,
38733907
timeout=self.feature_flags_request_timeout_seconds,
3908+
**self._request_identity_kwargs(),
38743909
)
38753910
except Exception as e:
38763911
self.log.exception(

‎posthog/consumer.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from posthog.capture_v1 import _backoff, _send_v1_batch
1111
from posthog.request import (
1212
EVENTS_ENDPOINT,
13+
USER_AGENT as _USER_AGENT,
1314
APIError,
1415
DatetimeSerializer,
1516
batch_post,
@@ -132,6 +133,7 @@ def __init__(
132133
self.max_msg_size = max_msg_size
133134
self.capture_mode = capture_mode
134135
self.capture_compression = capture_compression
136+
self._sdk_info = _USER_AGENT
135137
self._drain_signal: Optional[_DrainSignal] = None
136138
self._drain_on_stop = False
137139
# It's important to set running in the constructor: if we are asked to
@@ -300,6 +302,7 @@ def request(self, batch):
300302
timeout=self.timeout,
301303
max_retries=self.retries,
302304
historical_migration=self.historical_migration,
305+
sdk_info=self._sdk_info,
303306
)
304307
return
305308
self._send(batch, self.endpoint)
@@ -332,6 +335,11 @@ def is_retryable(exc):
332335
batch=batch,
333336
historical_migration=self.historical_migration,
334337
path=path,
338+
**(
339+
{"_user_agent": self._sdk_info}
340+
if self._sdk_info != _USER_AGENT
341+
else {}
342+
),
335343
)
336344
return
337345
except Exception as e:

‎posthog/mcp/README.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ analytics = instrument(server, posthog)
1616
Install is just `pip install posthog`. `instrument()` needs the MCP SDK at runtime,
1717
but anyone wrapping a server already has it.
1818

19+
MCP analytics events report `$lib: "posthog-python-mcp"`. Because `$lib` is a
20+
client-level identity, `instrument()` relabels every event sent by the client passed
21+
to it. Use a client dedicated to MCP analytics if the application also captures
22+
unrelated events.
23+
1924
## Stateless / multi-pod servers
2025

2126
A stateless MCP server issues no session id, so `$session_id` fragments across pods

‎posthog/mcp/__init__.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -303,6 +303,11 @@ def instrument(
303303
"on SDK 2.x, or jlowin's fastmcp.FastMCP) or a low-level mcp.server.Server."
304304
)
305305

306+
if client is not None:
307+
from ._lib_identity import apply_mcp_lib_identity
308+
309+
apply_mcp_lib_identity(client)
310+
306311
# Zero-config stateless minting: wrap the server's ASGI-app factories so a
307312
# stateless/multi-pod deployment keeps one $session_id + the client harness
308313
# across pods with no extra setup. No-op for stdio / low-level servers.

‎posthog/mcp/_lib_identity.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
"""Apply the MCP-specific identity to the underlying PostHog client."""
2+
3+
from __future__ import annotations
4+
5+
from ..client import Client
6+
7+
from .constants import POSTHOG_MCP_LIB_NAME
8+
from .version import __version__
9+
10+
11+
def apply_mcp_lib_identity(client: Client) -> None:
12+
"""Relabel every event sent by ``client`` as coming from ``posthog.mcp``."""
13+
set_identity = getattr(client, "_set_library_identity", None)
14+
if set_identity is not None:
15+
set_identity(POSTHOG_MCP_LIB_NAME, __version__)

‎posthog/mcp/constants.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
)
3434

3535
POSTHOG_MCP_ANALYTICS_SOURCE = "posthog_mcp_analytics"
36+
POSTHOG_MCP_LIB_NAME = "posthog-python-mcp"
3637

3738

3839
class PostHogMCPAnalyticsEvent:

‎posthog/mcp/posthog_mcp.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ._event_types import MCPAnalyticsEventType
2525
from ._exceptions import capture_exception
2626
from ._instrumentation import drain_pending_sync, fire_and_forget
27+
from ._lib_identity import apply_mcp_lib_identity
2728
from ._sink import McpCaptureOptions, McpEventSink
2829
from .tools import build_report_missing_descriptor
2930
from .types import (
@@ -51,6 +52,7 @@ def __init__(
5152
**kwargs: Any,
5253
) -> None:
5354
super().__init__(api_key, **kwargs)
55+
apply_mcp_lib_identity(self)
5456
self._mcp_sink = McpEventSink(self)
5557
self._missing_capability_tool_name = (
5658
missing_capability_tool_name or _GET_MORE_TOOLS_NAME

0 commit comments

Comments
 (0)