Skip to content
Closed
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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Framework rules

Follow these when integrating PostHog into this framework.

- A missing PostHog configuration must never break the app — read keys optionally (never a required setting), guard init and capture behind their presence, and keep build and boot working with no PostHog environment set — but never silently: in development or debug builds fail loudly, using the language's idiomatic error, with the message "<VAR> variable required by PostHog is missing or un-configured, this causes events to be silently missed. This error stops appearing once <VAR> is configured" (substituting the actual variable name); production stays a no-op
- posthog-node is the Node.js server-side SDK package name; posthog-js is browser-only, so use posthog-node on the server instead
- Include enableExceptionAutocapture: true in the PostHog constructor options
- Add posthog.capture() calls in route handlers for meaningful user actions – every route that creates, updates, or deletes data should track an event with contextual properties
- Add posthog.captureException(err, distinctId) in the application's error handler (e.g., Express error middleware, Fastify setErrorHandler, Koa app.on('error'))
- The SDK batches events and flushes asynchronously. await flush() or await shutdown() before letting that process exit. If unsure, set flushAt 1 and flushInterval 0.
- `posthog.capture()` enqueues synchronously and returns; the batched HTTP send happens afterwards. Treat every per-request handler as short-lived even when the framework feels like a server: Next.js / Nuxt / SvelteKit / Remix route handlers, serverless and edge functions, and Lambda are torn down per invocation before the send runs. Create the client with flushAt 1 and flushInterval 0, then await the send before returning. Always use `await posthog.flush()` for a shared/singleton client, `await posthog.shutdown()` for a per-request client. Never skip the awaited flush or risk the enqueued event being silently dropped.
- Reverse proxy is NOT needed for server-side Node.js – only client-side JavaScript needs a proxy to avoid ad blockers
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

# Conversation IDs - Docs

Copy page

# Conversation IDs - Docs

A PostHog `$session_id` is per MCP connection — it rotates when the protocol session does. That's the right granularity for "which TCP/WebSocket connection is this?" but it can split a single user conversation into multiple sessions when the client reconnects.

`$mcp_conversation_id` is an opt-in property that lets you stitch those calls together at the conversation level instead.

**Opt-in, with caveats**

Conversation IDs are off by default and rely on the agent cooperating. Read the caveats below before enabling — there's a visible side effect on tool responses, and the value is agent-controlled.

## Enabling

TypeScript

PostHog AI

```typescript
instrument(server, posthog, {
enableConversationId: true,
})
```

With this on, the SDK does three things:

1. **Injects an optional `conversation_id` argument** into every tool's JSON Schema, with a description telling the agent to reuse the value the server returns.
2. **Mints a UUID** when the agent calls a tool without `conversation_id`, and returns it on the tool's response as a `{"conversation_id":"…"}` text block — data, not an instruction.
3. **Captures the supplied or minted value** on every event as `$mcp_conversation_id`, distinct from `$session_id`.

The agent's `conversation_id` (when present) always wins. The SDK only mints when the agent doesn't supply one.

## How it lands in events

PostHog AI

```
{
event: "$mcp_tool_call",
properties: {
"$session_id": "ses_2a3f…", // MCP connection
"$mcp_conversation_id": "c_8b1d…", // logical conversation
"$mcp_tool_name": "search_events",
...
}
}
```

A new connection (new `$session_id`) made by the same agent re-using the same `conversation_id` will share `$mcp_conversation_id`. You can group by it in HogQL to see the whole conversation:

SQL

[Run in PostHog](https://us.posthog.com/sql?open_query=SELECT%0A++properties.%24mcp_conversation_id+AS+conversation%2C%0A++arrayDistinct%28groupArray%28properties.%24mcp_tool_name%29%29+AS+tools_called%2C%0A++count%28%29+AS+tool_calls%0AFROM+events%0AWHERE+event+%3D+'%24mcp_tool_call'%0A++AND+properties.%24mcp_conversation_id+IS+NOT+NULL%0A++AND+timestamp+%3E+now%28%29+-+INTERVAL+7+DAY%0AGROUP+BY+conversation%0AORDER+BY+tool_calls+DESC%0ALIMIT+50)

PostHog AI

```sql
SELECT
properties.$mcp_conversation_id AS conversation,
arrayDistinct(groupArray(properties.$mcp_tool_name)) AS tools_called,
count() AS tool_calls
FROM events
WHERE event = '$mcp_tool_call'
AND properties.$mcp_conversation_id IS NOT NULL
AND timestamp > now() - INTERVAL 7 DAY
GROUP BY conversation
ORDER BY tool_calls DESC
LIMIT 50
```

## Caveats

**Some tools can't take the injection**

The `conversation_id` parameter can't be added to a schema built from `oneOf` / `allOf` / `anyOf` / `$ref`, or to a tool with no input schema. Those tools log a warning and get no handle, so their calls won't correlate. [`identify`](/docs/mcp-analytics/identifying-users.md) covers them.

A client working from a **stale cached tool listing** won't know to send the parameter either — `ttlMs` caching on `tools/list` makes that more likely over time.

**The handle is visible in tool output**

It's returned as a `{"conversation_id":"…"}` text block, so consumers that surface raw tool-call content to end users will show that JSON. It's deliberately data rather than an instruction — an imperative sentence in tool output is indistinguishable from prompt injection, and hardened clients block it.

**Agent-controlled values**

When the agent supplies a `conversation_id`, the SDK accepts any non-empty string. You can bind it to your own session scheme (chat id, JWT `jti`, request id) by having the agent send that value, but nothing prevents a misbehaving client from sending arbitrary strings. Don't use `$mcp_conversation_id` as a security boundary.

**It's not a session id**

`$session_id` is what PostHog's session-level joins and identity resolution use. `$mcp_conversation_id` is purely a logical grouping label — handy for joins, useless for everything else.

## When to skip this

If your MCP server runs over a long-lived connection that already aligns with what you'd call a "conversation" — for example, a stdio server attached to a single Claude Desktop chat — `$session_id` is already doing the right thing. Leave `enableConversationId` off.

Turn it on when:

- The same logical conversation crosses connections (HTTP/SSE clients that reconnect).
- You want to correlate MCP events with a conversation id you already own elsewhere (chat platform, support ticket, JWT) and you're happy to plumb that id through the agent.

### Still have questions?

Ask PostHog AI

### Was this page useful?

HelpfulCould be better
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
> AI agents: this is one page from PostHog's docs. Full index of Markdown docs for LLMs: https://posthog.com/llms.txt

# Custom events and metadata - Docs

Copy page

# Custom events and metadata - Docs

Sometimes the events the SDK emits out of the box aren't enough. You might want to attach metadata to every event, or emit a fully custom event for something that isn't a tool call. The SDK gives you two hooks for that, in order of increasing invasiveness.

## `eventProperties` — metadata on every event

Pass an `eventProperties` callback to attach extra properties to every event the SDK emits. The callback runs per request, so values can depend on the current call (request id, transport, headers, env, region, deploy SHA, etc).

TypeScript

PostHog AI

```typescript
import { instrument, getRequestHeaders } from "@posthog/mcp"
const analytics = instrument(server, posthog, {
eventProperties: async (request, extra) => ({
$app_version: process.env.GIT_SHA ?? "unknown",
$mcp_region: process.env.FLY_REGION ?? "unknown",
request_id: getRequestHeaders(extra)?.["x-request-id"],
}),
})
```

`getRequestHeaders` reads headers on both MCP SDK majors — see [MCP SDK v2](/docs/mcp-analytics/sdk-v2.md#if-your-callbacks-read-headers-change-them).

The returned object is spread flat onto the event's properties alongside the built-in `$mcp_*` keys:

JSON

PostHog AI

```json
{
"event": "$mcp_tool_call",
"properties": {
"$mcp_tool_name": "search_events",
"$app_version": "a1b2c3d",
"$mcp_region": "iad",
"request_id": "req_…",
"…"
}
}
```

For a "stamp on everything" use case (the closest analogue to `posthog.register(...)` in other SDKs), just return constants from the callback. The callback is per-event rather than session-persistent, so the values can also vary per request if you need them to.

For group analytics, return `groups` from your [`identify`](/docs/mcp-analytics/identifying-users.md) callback rather than hand-writing the `$groups` key — the SDK stamps `$groups` onto every event for the session for you.

Returned values must be JSON-serializable. Errors thrown from your callback are swallowed and surfaced to your `logger` — they never interrupt tool execution.

## `analytics.capture()` — emit an arbitrary event

When the built-in events don't cover what you need — for example, recording a feedback signal from your own UI, or capturing a domain event that isn't an MCP request — use the `capture()` method on the handle returned by `instrument()`. It writes onto the same queue as everything else, so it inherits the SDK's sanitization, identity, `beforeSend`, and `eventProperties` logic. `capture()` returns a promise you can `await`.

You name the event. It's sent verbatim — it's your event, so it is **not** `$`\-prefixed.

TypeScript

PostHog AI

```typescript
const analytics = instrument(server, posthog)
await analytics.capture({
event: "feedback_submitted",
properties: { rating: 5 },
})
```

What lands in PostHog:

- One event under the verbatim `event` name you passed, with your `properties` merged in.
- The session id, identity, and any `eventProperties` callback still apply.

`capture()` is a method on the handle that `instrument()` returns, so you call it on the instrumented server's analytics handle directly.

## Which one to use

| You want to... | Use |
| --- | --- |
| Attach the same properties to every auto-captured event | eventProperties |
| Emit a one-off event that isn't an MCP request | analytics.capture() |
| Attach data to a specific tool call (just that one) | Not directly supported — the callbacks run on every event. The SDK doesn't currently expose a per-call hook. |

### Still have questions?

Ask PostHog AI

### Was this page useful?

HelpfulCould be better
Loading
Loading