Skip to content

Commit d234b53

Browse files
authored
feat(ai): add Claude Agent SDK integration for LLM analytics (#477)
* feat(ai): add Claude Agent SDK integration for LLM analytics Add posthog.ai.claude_agent_sdk module that wraps claude_agent_sdk.query() to automatically emit $ai_generation, $ai_span, and $ai_trace events. - PostHogClaudeAgentProcessor with _GenerationTracker that reconstructs per-turn generation metrics from Anthropic StreamEvents - Two entry points: query() drop-in replacement and instrument() for configure-once reuse - Two-slot input tracking to correctly associate tool results with subsequent generations despite SDK message ordering - All instrumentation wrapped in try/except so PostHog errors never interrupt the underlying Claude Agent SDK query - 16 unit tests covering generation, multi-turn, fallback, tool spans, traces, privacy mode, personless mode, custom properties - Example scripts (simple_query.py, instrument_reuse.py) * fix(ai): address review feedback on claude agent sdk integration - Honor per-call privacy override for $ai_input/$ai_output_choices in generation events (was only checking instance-level privacy) - Pass groups directly to _capture_event instead of fragile save/restore pattern on self._groups (thread-safe, exception-safe) - Fix tool span parent linkage: use tracker.current_span_id for in-progress generation instead of stale current_generation_span_id * style: ruff format claude_agent_sdk files * feat(ai): add PostHogClaudeSDKClient for stateful multi-turn conversations Wraps ClaudeSDKClient to instrument receive_response() with the same generation/span/trace tracking as query(). Supports multi-turn conversations with full history — each turn emits its own $ai_generation events, all linked by a shared $ai_trace_id. The $ai_trace event is emitted on disconnect() to cover the entire session. Usage: async with PostHogClaudeSDKClient(options, posthog_client=ph) as client: await client.query("Hello") async for msg in client.receive_response(): ... await client.query("Follow up") # has conversation history async for msg in client.receive_response(): ... * docs(ai): add multi-turn conversation example for Claude Agent SDK * refactor(ai): split PostHogClaudeSDKClient into client.py * style: ruff format multi_turn.py example * fix: resolve ruff lint errors in claude agent sdk files
1 parent 0fdbc2e commit d234b53

15 files changed

Lines changed: 2099 additions & 0 deletions

File tree

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+
feat(ai): add Claude Agent SDK integration for LLM analytics
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
POSTHOG_API_KEY=phc_your_project_api_key
2+
POSTHOG_HOST=https://us.i.posthog.com
3+
ANTHROPIC_API_KEY=sk-ant-your_api_key
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Claude Agent SDK + PostHog AI Examples
2+
3+
Track Claude Agent SDK calls with PostHog.
4+
5+
## Setup
6+
7+
```bash
8+
pip install -r requirements.txt
9+
cp .env.example .env
10+
# Fill in your API keys in .env
11+
```
12+
13+
## Examples
14+
15+
- **simple_query.py** - Single query using the `query()` drop-in replacement
16+
- **instrument_reuse.py** - Configure-once with `instrument()`, reuse across multiple queries
17+
- **multi_turn.py** - Multi-turn conversation with history using `PostHogClaudeSDKClient`
18+
19+
## Run
20+
21+
```bash
22+
source .env
23+
python simple_query.py
24+
python instrument_reuse.py
25+
python multi_turn.py
26+
```
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
"""Claude Agent SDK with instrument() for reusable config, tracked by PostHog."""
2+
3+
import asyncio
4+
import os
5+
6+
from claude_agent_sdk import ClaudeAgentOptions, AssistantMessage, TextBlock
7+
from posthog import Posthog
8+
from posthog.ai.claude_agent_sdk import instrument
9+
10+
posthog = Posthog(
11+
os.environ["POSTHOG_API_KEY"],
12+
host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"),
13+
)
14+
15+
# Configure once, reuse for multiple queries
16+
ph = instrument(
17+
client=posthog,
18+
distinct_id="example-user",
19+
properties={"app": "demo", "environment": "development"},
20+
)
21+
22+
23+
async def ask(prompt: str) -> None:
24+
print(f"\n> {prompt}")
25+
options = ClaudeAgentOptions(
26+
max_turns=2,
27+
permission_mode="plan",
28+
)
29+
30+
async for message in ph.query(prompt=prompt, options=options):
31+
if isinstance(message, AssistantMessage):
32+
for block in message.content:
33+
if isinstance(block, TextBlock):
34+
print(f" {block.text}")
35+
36+
37+
async def main():
38+
await ask("What is the capital of France? Reply in one sentence.")
39+
await ask("What is 15% of 280? Reply in one sentence.")
40+
41+
42+
asyncio.run(main())
43+
posthog.shutdown()
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
"""Claude Agent SDK multi-turn conversation with history, tracked by PostHog."""
2+
3+
import asyncio
4+
import os
5+
6+
from claude_agent_sdk import AssistantMessage, ResultMessage
7+
from claude_agent_sdk.types import ClaudeAgentOptions, TextBlock
8+
from posthog import Posthog
9+
from posthog.ai.claude_agent_sdk import PostHogClaudeSDKClient
10+
11+
posthog = Posthog(
12+
os.environ["POSTHOG_API_KEY"],
13+
host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"),
14+
)
15+
16+
17+
async def main():
18+
options = ClaudeAgentOptions(
19+
max_turns=5,
20+
permission_mode="plan",
21+
)
22+
23+
async with PostHogClaudeSDKClient(
24+
options,
25+
posthog_client=posthog,
26+
posthog_distinct_id="example-user",
27+
posthog_properties={"example": "multi_turn"},
28+
) as client:
29+
# Turn 1
30+
print("> What is the capital of France?")
31+
await client.query("What is the capital of France? Reply in one sentence.")
32+
async for message in client.receive_response():
33+
if isinstance(message, AssistantMessage):
34+
for block in message.content:
35+
if isinstance(block, TextBlock):
36+
print(f" {block.text}")
37+
elif isinstance(message, ResultMessage):
38+
print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]")
39+
40+
# Turn 2 — has full conversation history
41+
print("\n> And what language do they speak there?")
42+
await client.query(
43+
"And what language do they speak there? Reply in one sentence."
44+
)
45+
async for message in client.receive_response():
46+
if isinstance(message, AssistantMessage):
47+
for block in message.content:
48+
if isinstance(block, TextBlock):
49+
print(f" {block.text}")
50+
elif isinstance(message, ResultMessage):
51+
print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]")
52+
53+
# Turn 3 — still has context from both previous turns
54+
print("\n> How do you say 'hello' in that language?")
55+
await client.query(
56+
"How do you say 'hello' in that language? Reply in one sentence."
57+
)
58+
async for message in client.receive_response():
59+
if isinstance(message, AssistantMessage):
60+
for block in message.content:
61+
if isinstance(block, TextBlock):
62+
print(f" {block.text}")
63+
elif isinstance(message, ResultMessage):
64+
print(f" [{message.num_turns} turns, ${message.total_cost_usd:.4f}]")
65+
66+
67+
asyncio.run(main())
68+
posthog.shutdown()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
posthog>=7.9.12
2+
claude-agent-sdk
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
"""Claude Agent SDK simple query, tracked by PostHog."""
2+
3+
import asyncio
4+
import os
5+
6+
from claude_agent_sdk import ClaudeAgentOptions
7+
from posthog import Posthog
8+
from posthog.ai.claude_agent_sdk import query
9+
10+
posthog = Posthog(
11+
os.environ["POSTHOG_API_KEY"],
12+
host=os.environ.get("POSTHOG_HOST", "https://us.i.posthog.com"),
13+
)
14+
15+
16+
async def main():
17+
options = ClaudeAgentOptions(
18+
max_turns=2,
19+
permission_mode="plan",
20+
)
21+
22+
async for message in query(
23+
prompt="What is 2 + 2? Reply in one sentence.",
24+
options=options,
25+
posthog_client=posthog,
26+
posthog_distinct_id="example-user",
27+
posthog_properties={"example": "simple_query"},
28+
):
29+
print(f"[{type(message).__name__}]")
30+
31+
32+
asyncio.run(main())
33+
posthog.shutdown()
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
exclude-newer = "7 days"
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
4+
5+
if TYPE_CHECKING:
6+
from claude_agent_sdk.types import ClaudeAgentOptions, ResultMessage
7+
8+
from posthog.client import Client
9+
10+
try:
11+
import claude_agent_sdk # noqa: F401
12+
except ImportError:
13+
raise ModuleNotFoundError(
14+
"Please install the Claude Agent SDK to use this feature: 'pip install claude-agent-sdk'"
15+
)
16+
17+
from posthog.ai.claude_agent_sdk.client import PostHogClaudeSDKClient
18+
from posthog.ai.claude_agent_sdk.processor import PostHogClaudeAgentProcessor
19+
20+
__all__ = [
21+
"PostHogClaudeAgentProcessor",
22+
"PostHogClaudeSDKClient",
23+
"instrument",
24+
"query",
25+
]
26+
27+
28+
def instrument(
29+
client: Optional[Client] = None,
30+
distinct_id: Optional[Union[str, Callable[[ResultMessage], Optional[str]]]] = None,
31+
privacy_mode: bool = False,
32+
groups: Optional[Dict[str, Any]] = None,
33+
properties: Optional[Dict[str, Any]] = None,
34+
) -> PostHogClaudeAgentProcessor:
35+
"""
36+
Create a PostHog-instrumented query wrapper for the Claude Agent SDK.
37+
38+
Returns a PostHogClaudeAgentProcessor whose .query() method is a drop-in
39+
replacement for claude_agent_sdk.query() that automatically emits
40+
$ai_generation, $ai_span, and $ai_trace events.
41+
42+
Args:
43+
client: Optional PostHog client instance. If not provided, uses the default client.
44+
distinct_id: Optional distinct ID to associate with all events.
45+
Can also be a callable that takes a ResultMessage and returns a distinct ID.
46+
privacy_mode: If True, redacts sensitive information in tracking.
47+
groups: Optional PostHog groups to associate with events.
48+
properties: Optional additional properties to include with all events.
49+
50+
Returns:
51+
PostHogClaudeAgentProcessor: A processor whose .query() method wraps claude_agent_sdk.query().
52+
53+
Example:
54+
```python
55+
from posthog.ai.claude_agent_sdk import instrument
56+
57+
ph = instrument(distinct_id="my-app", properties={"env": "prod"})
58+
59+
async for message in ph.query(prompt="Hello", options=options):
60+
print(message)
61+
```
62+
"""
63+
return PostHogClaudeAgentProcessor(
64+
client=client,
65+
distinct_id=distinct_id,
66+
privacy_mode=privacy_mode,
67+
groups=groups,
68+
properties=properties,
69+
)
70+
71+
72+
async def query(
73+
*,
74+
prompt: Any,
75+
options: Optional[ClaudeAgentOptions] = None,
76+
transport: Any = None,
77+
posthog_client: Optional[Client] = None,
78+
posthog_distinct_id: Optional[
79+
Union[str, Callable[[ResultMessage], Optional[str]]]
80+
] = None,
81+
posthog_trace_id: Optional[str] = None,
82+
posthog_properties: Optional[Dict[str, Any]] = None,
83+
posthog_privacy_mode: bool = False,
84+
posthog_groups: Optional[Dict[str, Any]] = None,
85+
):
86+
"""
87+
Drop-in replacement for claude_agent_sdk.query() with PostHog instrumentation.
88+
89+
All original messages are yielded unchanged. PostHog events ($ai_generation,
90+
$ai_span, $ai_trace) are emitted automatically.
91+
92+
Args:
93+
prompt: The prompt (same as claude_agent_sdk.query)
94+
options: ClaudeAgentOptions (same as claude_agent_sdk.query)
95+
transport: Optional transport (same as claude_agent_sdk.query)
96+
posthog_client: Optional PostHog client instance.
97+
posthog_distinct_id: Optional distinct ID for this query.
98+
posthog_trace_id: Optional trace ID (auto-generated if not provided).
99+
posthog_properties: Extra properties to include with all events.
100+
posthog_privacy_mode: If True, redacts sensitive content.
101+
posthog_groups: Optional PostHog groups.
102+
103+
Example:
104+
```python
105+
from posthog.ai.claude_agent_sdk import query
106+
107+
async for message in query(
108+
prompt="Hello",
109+
options=options,
110+
posthog_distinct_id="my-app",
111+
posthog_properties={"pr_number": 123},
112+
):
113+
print(message)
114+
```
115+
"""
116+
processor = PostHogClaudeAgentProcessor(
117+
client=posthog_client,
118+
distinct_id=posthog_distinct_id,
119+
privacy_mode=posthog_privacy_mode,
120+
groups=posthog_groups,
121+
properties={},
122+
)
123+
124+
async for message in processor.query(
125+
prompt=prompt,
126+
options=options,
127+
transport=transport,
128+
posthog_trace_id=posthog_trace_id,
129+
posthog_properties=posthog_properties,
130+
posthog_privacy_mode=posthog_privacy_mode,
131+
posthog_groups=posthog_groups,
132+
):
133+
yield message

0 commit comments

Comments
 (0)