Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ snapshots:
pricing--desktop:
hash: v1.k54649d6d.86c2b292ff02e47ad5108af5ba373f2ca35dcfa21f348eb9ea1b42e359738b62.A6B3pTKcyehwxJvuKPSGckDNlu03eMegU-3RiSowieI
pricing--mobile:
hash: v1.k54649d6d.0960f404d5bac7e7e64fe38f7ff21cadecc89d960074be9725c5fc8dfcb80d1b.GlhFE8hjyRL2xppjOhGeqZ5KhJU3-QPyD0W94lsdlOU
hash: v1.k54649d6d.c4a01959d853183f465782d56ce1d0a6bd6f1385f1cd03349430f937c0dc1e59.oJN4qafmXzE03Fl0tudiFZKdSAvyHQHy3TG_tP02q2U
product-analytics--desktop:
hash: v1.k54649d6d.93209c9c499bc9651ad6991098a9c7d0deeed55f40b3f72f7c7a835e3177627a.9723yEHo8tmRt44URKU_IitJC9NrSqAVBV9HFb2W2H0
product-analytics--mobile:
Expand All @@ -43,4 +43,4 @@ snapshots:
surveys--desktop:
hash: v1.k54649d6d.fff1c7652fe7cfc4de61ea6fd8c9821255cefbcfc9d956423f525bcc8a633cc4.SQoHuWZAI_t0dALwUc-xYOqI8CfCuAAjB4REZWt2Rjs
surveys--mobile:
hash: v1.k54649d6d.fa499a502301a1d93c877a9ac62ca9df2d64fd405e1db502ba1b543b1674b57d.Tz1MqBKasGzORj_prX6kW9JKCdf619Y8Ta2fPiK82nM
hash: v1.k54649d6d.3bfb92842973342add36bb85fda355a8cd5b718d5df557e7de7dfe6f63c8ccd2.MzHKvwQpkAT-tT2DknX-xnOwltCkNfW_QbWCd3UVsic
26 changes: 26 additions & 0 deletions contents/docs/mcp-analytics/custom-servers.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,29 @@ posthog.flush() # PostHogMCP is a posthog client — flush/shutdown it yourself
```

`PostHogMCP(api_key, missing_capability_tool_name="get_more_tools", mcp_exception_autocapture=True, **posthog_kwargs)` accepts the standard `posthog` client kwargs (e.g. `host`). Set `mcp_exception_autocapture=False` to stop a failed tool call from emitting a `$exception` sibling. As in TypeScript, the wrapping-path hooks (`identify`, `context`, `intent_fallback`, `event_properties`) don't apply here — pass identity and properties on each `capture_*` call.

### Stateless / multi-pod dispatchers

On a stateless deployment (a fresh server per request, often across pods) there's no connection to carry a session, so `$session_id` fragments and the client name/version — sent only at `initialize` — go missing from later requests. Add the mint middleware to your ASGI app once. It mints a self-encoded token onto the `Mcp-Session-Id` response header at `initialize` and decodes the client's replay on every later request, so every pod recovers the same values with no shared store:

```python
from posthog.mcp import PostHogMcpStatelessSessionMiddleware, get_mcp_session

app.add_middleware(PostHogMcpStatelessSessionMiddleware)

# ...then in your request handler, feed the recovered session into each capture.
# The token carries the client identity too — pass it as $mcp_client_* properties
# (capture_tool_call takes session_id directly, client name/version via properties):
sess = get_mcp_session(request) # None until the client replays the token
posthog.capture_tool_call(
name,
session_id=sess.session_id if sess else None,
intent=prepared.intent,
properties={
"$mcp_client_name": sess.client_name if sess else None,
"$mcp_client_version": sess.client_version if sess else None,
},
)
```

The token is unsigned and carries only what the client volunteered at `initialize` — treat `$session_id` and `$mcp_client_*` as analytics labels, not authentication.
26 changes: 20 additions & 6 deletions contents/docs/mcp-analytics/installation.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -219,12 +219,6 @@ if (body?.method === "initialize" && !req.headers[MCP_SESSION_HEADER]) {

Some frameworks construct the transport for you and don't expose `enableJsonResponse`, and a client that ignores the header falls back to a generated session per request either way. In both cases, group by user with [`identify`](/docs/mcp-analytics/identifying-users) — `distinct_id` groups a person's calls however many requests they span, and it requires nothing from the client. [Conversation IDs](/docs/mcp-analytics/conversation-id) give finer, per-conversation grouping when the agent cooperates.

<CalloutBox icon="IconInfo" title="Python" type="fyi">

Session tokens are TypeScript-only today. A Python server on a stateless deployment gets a session per request — group with `identify` as above.

</CalloutBox>

## Python

A Python SDK ships inside the [`posthog`](/docs/libraries/python) package (the same way [`posthog.ai`](/docs/ai-engineering) does), so there's nothing extra to install:
Expand Down Expand Up @@ -286,6 +280,26 @@ instrument(server, posthog, MCPAnalyticsOptions(
| `event_properties` | `(request, extra) -> dict` | — | Properties merged onto every event. |
| `logger` | `(message: str) -> None` | no-op | STDIO-safe log sink. |

### Stateless and multi-pod servers

Same problem as [above](#stateless-and-multi-pod-servers) — a stateless deployment fragments `$session_id` and loses the client name/version after `initialize`. Python fixes it with the same self-encoded session token (minted onto the `Mcp-Session-Id` header, replayed by the client), but from an ASGI layer, so there's **no `enableJsonResponse` caveat** — JSON and SSE both work.

On a **FastMCP** server (official `mcp.server.fastmcp` or jlowin's `fastmcp` 2.0) it's zero-config: `instrument()` wraps the server's `streamable_http_app()` / `sse_app()` factories (which `run()` uses too), so just make the server stateless:

```python
server = FastMCP("my-server", stateless_http=True)
instrument(server, posthog)
server.run(transport="streamable-http") # or: app = server.streamable_http_app()
```

When you build the ASGI app yourself — a low-level `Server`, or a custom [`PostHogMCP`](/docs/mcp-analytics/custom-servers) dispatcher — add the middleware to that app once:

```python
from posthog.mcp import PostHogMcpStatelessSessionMiddleware

app.add_middleware(PostHogMcpStatelessSessionMiddleware)
```

### Flushing on exit

The `posthog` client batches events asynchronously and you own its lifecycle. On the `instrument()` path, auto-captured events are scheduled in the background — `await analytics.flush()` waits for in-flight events, then `posthog.flush()` / `posthog.shutdown()` sends them. Call this from your shutdown/`SIGTERM` handler so trailing events aren't dropped (see [`examples/mcp_analytics_demo.py`](https://github.com/PostHog/posthog-python/blob/main/examples/mcp_analytics_demo.py) for a runnable end-to-end example):
Expand Down
Loading