|
21 | 21 | from ._exceptions import capture_exception |
22 | 22 | from ._intent import resolve_tool_call_intent, set_event_intent |
23 | 23 | from ._internal import MCPAnalyticsData, handle_identify, resolve_event_properties |
24 | | -from .logger import log |
| 24 | +from .logger import log, warn |
| 25 | +from .request_headers import get_request |
25 | 26 | from ._sanitization import build_captured_mcp_parameters |
26 | 27 | from ._transport_identity import stamp_transport_identity |
27 | | -from .session import resolve_session_id |
| 28 | +from .session import resolve_session_id, resolve_session_id_with_source |
28 | 29 | from .session_token import SessionTokenPayload, decode_session_id |
29 | 30 |
|
30 | 31 | # Keep strong refs to in-flight capture tasks/futures and their lifecycle owners so |
@@ -268,6 +269,46 @@ async def prime_session( |
268 | 269 | await resolve_session_id(data, mcp_session_id, token=token) |
269 | 270 |
|
270 | 271 |
|
| 272 | +def _is_sse_request(extra: Optional[Dict[str, Any]]) -> bool: |
| 273 | + """True for the deprecated SSE transport, which carries its session as a |
| 274 | + ``session_id`` query parameter rather than a header. |
| 275 | +
|
| 276 | + Such a request resolves to a ``generated`` session for a reason the stateless |
| 277 | + mint cannot fix -- the mint sets a response header an SSE client never replays -- |
| 278 | + so :func:`_warn_stateless_session_not_wired` would be recommending a remedy that |
| 279 | + does not apply.""" |
| 280 | + try: |
| 281 | + params = getattr(get_request(extra), "query_params", None) |
| 282 | + return bool(params is not None and params.get("session_id")) |
| 283 | + except Exception: # noqa: BLE001 - a transport probe must never break a tool call |
| 284 | + return False |
| 285 | + |
| 286 | + |
| 287 | +def _warn_stateless_session_not_wired(data: MCPAnalyticsData) -> None: |
| 288 | + """Warn once per server when a tool call/listing arrives over HTTP but the |
| 289 | + session still had to come from this process's memory. |
| 290 | +
|
| 291 | + That is the fingerprint of a stateless/multi-pod server whose mint middleware |
| 292 | + never attached — most often because the ASGI app was built (or mounted from |
| 293 | + another module) *before* ``instrument()`` ran, so wrapping the app factories |
| 294 | + couldn't retrofit the already-built app. The result is a silently fragmented |
| 295 | + ``$session_id``; this makes that failure loud instead of dark-in-prod.""" |
| 296 | + if data.warned_no_stateless_session: |
| 297 | + return |
| 298 | + data.warned_no_stateless_session = True |
| 299 | + warn( |
| 300 | + "Warning: an MCP tool request arrived over streamable HTTP with no session id, so " |
| 301 | + "PostHog generated a per-process $session_id that will fragment across requests " |
| 302 | + "and pods. This usually means PostHogMcpStatelessSessionMiddleware never attached " |
| 303 | + "— e.g. the ASGI app was built or mounted before instrument() ran. If you build " |
| 304 | + "the app yourself, add the middleware explicitly: " |
| 305 | + "app.add_middleware(PostHogMcpStatelessSessionMiddleware). " |
| 306 | + "Enabling conversation ids (MCPAnalyticsOptions(enable_conversation_id=True)) also " |
| 307 | + "anchors the session without any middleware. " |
| 308 | + "See posthog/mcp/README.md (stateless / multi-pod servers)." |
| 309 | + ) |
| 310 | + |
| 311 | + |
271 | 312 | async def prepare_request( |
272 | 313 | data: MCPAnalyticsData, |
273 | 314 | *, |
@@ -305,10 +346,20 @@ async def prepare_request( |
305 | 346 | when ``capture_event`` builds the initialize event — otherwise the first |
306 | 347 | ``$mcp_initialize`` is anonymous even when identify resolves on the same request. |
307 | 348 | (Still not byte-parity with the TS SDK, which wraps the real initialize handler; |
308 | | - the Python SDK handles initialize in the session layer, not ``request_handlers``.)""" |
309 | | - session_id = await resolve_session_id( |
| 349 | + the Python SDK handles initialize in the session layer, not ``request_handlers``.) |
| 350 | +
|
| 351 | + A request that reached us over HTTP yet still resolved to this process's memory |
| 352 | + has nothing correlating it across pods, which on a stateless server means the |
| 353 | + mint middleware never attached — warn once rather than fragment silently.""" |
| 354 | + session_id, session_source = await resolve_session_id_with_source( |
310 | 355 | data, mcp_session_id, token=token, conversation_id=conversation_id |
311 | 356 | ) |
| 357 | + if ( |
| 358 | + session_source == "generated" |
| 359 | + and get_request(extra) is not None |
| 360 | + and not _is_sse_request(extra) |
| 361 | + ): |
| 362 | + _warn_stateless_session_not_wired(data) |
312 | 363 | identify_event = await handle_identify(data, session_id, request, extra) |
313 | 364 | if identify_event: |
314 | 365 | fire_and_forget(capture_event(data, identify_event), data) |
|
0 commit comments