Skip to content
Open
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
5 changes: 5 additions & 0 deletions .sampo/changesets/claude-agent-sdk-integration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: minor
---

feat(ai): add Claude Agent SDK integration for LLM analytics
3 changes: 3 additions & 0 deletions examples/example-ai-claude-agent-sdk/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
POSTHOG_API_KEY=phc_your_project_api_key
POSTHOG_HOST=https://us.i.posthog.com
ANTHROPIC_API_KEY=sk-ant-your_api_key
26 changes: 26 additions & 0 deletions examples/example-ai-claude-agent-sdk/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Claude Agent SDK + PostHog AI Examples

Track Claude Agent SDK calls with PostHog.

## Setup

```bash
pip install -r requirements.txt
cp .env.example .env
# Fill in your API keys in .env
```

## Examples

- **simple_query.py** - Single query using the `query()` drop-in replacement
- **instrument_reuse.py** - Configure-once with `instrument()`, reuse across multiple queries
- **multi_turn.py** - Multi-turn conversation with history using `PostHogClaudeSDKClient`

## Run

```bash
source .env
python simple_query.py
python instrument_reuse.py
python multi_turn.py
```
43 changes: 43 additions & 0 deletions examples/example-ai-claude-agent-sdk/instrument_reuse.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
"""Claude Agent SDK with instrument() for reusable config, tracked by PostHog."""

import asyncio
import os

from claude_agent_sdk import ClaudeAgentOptions, AssistantMessage, TextBlock
from posthog import Posthog
from posthog.ai.claude_agent_sdk import instrument

posthog = Posthog(
os.environ["POSTHOG_API_KEY"],
host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"),
)

# Configure once, reuse for multiple queries
ph = instrument(
client=posthog,
distinct_id="example-user",
properties={"app": "demo", "environment": "development"},
)


async def ask(prompt: str) -> None:
print(f"\n> {prompt}")
options = ClaudeAgentOptions(
max_turns=2,
permission_mode="plan",
)

async for message in ph.query(prompt=prompt, options=options):
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f" {block.text}")


async def main():
await ask("What is the capital of France? Reply in one sentence.")
await ask("What is 15% of 280? Reply in one sentence.")


asyncio.run(main())
posthog.shutdown()
64 changes: 64 additions & 0 deletions examples/example-ai-claude-agent-sdk/multi_turn.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Claude Agent SDK multi-turn conversation with history, tracked by PostHog."""

import asyncio
import os

from claude_agent_sdk import AssistantMessage, ResultMessage
from claude_agent_sdk.types import ClaudeAgentOptions, TextBlock, ToolUseBlock
from posthog import Posthog
from posthog.ai.claude_agent_sdk import PostHogClaudeSDKClient

posthog = Posthog(
os.environ["POSTHOG_API_KEY"],
host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"),
)


async def main():
options = ClaudeAgentOptions(
max_turns=5,
permission_mode="plan",
)

async with PostHogClaudeSDKClient(
options,
posthog_client=posthog,
posthog_distinct_id="example-user",
posthog_properties={"example": "multi_turn"},
) as client:
# Turn 1
print("> What is the capital of France?")
await client.query("What is the capital of France? Reply in one sentence.")
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f" {block.text}")
elif isinstance(message, ResultMessage):
print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]")

# Turn 2 — has full conversation history
print("\n> And what language do they speak there?")
await client.query("And what language do they speak there? Reply in one sentence.")
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f" {block.text}")
elif isinstance(message, ResultMessage):
print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]")

# Turn 3 — still has context from both previous turns
print("\n> How do you say 'hello' in that language?")
await client.query("How do you say 'hello' in that language? Reply in one sentence.")
async for message in client.receive_response():
if isinstance(message, AssistantMessage):
for block in message.content:
if isinstance(block, TextBlock):
print(f" {block.text}")
elif isinstance(message, ResultMessage):
print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]")


asyncio.run(main())
posthog.shutdown()
2 changes: 2 additions & 0 deletions examples/example-ai-claude-agent-sdk/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
posthog>=7.9.12
claude-agent-sdk
33 changes: 33 additions & 0 deletions examples/example-ai-claude-agent-sdk/simple_query.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Claude Agent SDK simple query, tracked by PostHog."""

import asyncio
import os

from claude_agent_sdk import ClaudeAgentOptions
from posthog import Posthog
from posthog.ai.claude_agent_sdk import query

posthog = Posthog(
os.environ["POSTHOG_API_KEY"],
host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"),
)


async def main():
options = ClaudeAgentOptions(
max_turns=2,
permission_mode="plan",
)

async for message in query(
prompt="What is 2 + 2? Reply in one sentence.",
options=options,
posthog_client=posthog,
posthog_distinct_id="example-user",
posthog_properties={"example": "simple_query"},
):
print(f"[{type(message).__name__}]")


asyncio.run(main())
posthog.shutdown()
1 change: 1 addition & 0 deletions examples/example-ai-claude-agent-sdk/uv.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
exclude-newer = "7 days"
133 changes: 133 additions & 0 deletions posthog/ai/claude_agent_sdk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union

if TYPE_CHECKING:
from claude_agent_sdk.types import ClaudeAgentOptions, ResultMessage

from posthog.client import Client

try:
import claude_agent_sdk # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'"
)

from posthog.ai.claude_agent_sdk.client import PostHogClaudeSDKClient
from posthog.ai.claude_agent_sdk.processor import PostHogClaudeAgentProcessor

__all__ = [
"PostHogClaudeAgentProcessor",
"PostHogClaudeSDKClient",
"instrument",
"query",
]


def instrument(
client: Optional[Client] = None,
distinct_id: Optional[Union[str, Callable[[ResultMessage], Optional[str]]]] = None,
privacy_mode: bool = False,
groups: Optional[Dict[str, Any]] = None,
properties: Optional[Dict[str, Any]] = None,
) -> PostHogClaudeAgentProcessor:
"""
Create a PostHog-instrumented query wrapper for the Claude Agent SDK.

Returns a PostHogClaudeAgentProcessor whose .query() method is a drop-in
replacement for claude_agent_sdk.query() that automatically emits
$ai_generation, $ai_span, and $ai_trace events.

Args:
client: Optional PostHog client instance. If not provided, uses the default client.
distinct_id: Optional distinct ID to associate with all events.
Can also be a callable that takes a ResultMessage and returns a distinct ID.
privacy_mode: If True, redacts sensitive information in tracking.
groups: Optional PostHog groups to associate with events.
properties: Optional additional properties to include with all events.

Returns:
PostHogClaudeAgentProcessor: A processor whose .query() method wraps claude_agent_sdk.query().

Example:
```python
from posthog.ai.claude_agent_sdk import instrument

ph = instrument(distinct_id="my-app", properties={"env": "prod"})

async for message in ph.query(prompt="Hello", options=options):
print(message)
```
"""
return PostHogClaudeAgentProcessor(
client=client,
distinct_id=distinct_id,
privacy_mode=privacy_mode,
groups=groups,
properties=properties,
)


async def query(
*,
prompt: Any,
options: Optional[ClaudeAgentOptions] = None,
transport: Any = None,
posthog_client: Optional[Client] = None,
posthog_distinct_id: Optional[
Union[str, Callable[[ResultMessage], Optional[str]]]
] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
):
"""
Drop-in replacement for claude_agent_sdk.query() with PostHog instrumentation.

All original messages are yielded unchanged. PostHog events ($ai_generation,
$ai_span, $ai_trace) are emitted automatically.

Args:
prompt: The prompt (same as claude_agent_sdk.query)
options: ClaudeAgentOptions (same as claude_agent_sdk.query)
transport: Optional transport (same as claude_agent_sdk.query)
posthog_client: Optional PostHog client instance.
posthog_distinct_id: Optional distinct ID for this query.
posthog_trace_id: Optional trace ID (auto-generated if not provided).
posthog_properties: Extra properties to include with all events.
posthog_privacy_mode: If True, redacts sensitive content.
posthog_groups: Optional PostHog groups.

Example:
```python
from posthog.ai.claude_agent_sdk import query

async for message in query(
prompt="Hello",
options=options,
posthog_distinct_id="my-app",
posthog_properties={"pr_number": 123},
):
print(message)
```
"""
processor = PostHogClaudeAgentProcessor(
client=posthog_client,
distinct_id=posthog_distinct_id,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
properties={},
)

async for message in processor.query(
prompt=prompt,
options=options,
transport=transport,
posthog_trace_id=posthog_trace_id,
posthog_properties=posthog_properties,
posthog_privacy_mode=posthog_privacy_mode,
posthog_groups=posthog_groups,
):
yield message
Loading
Loading