rudderstack-ai
Product analytics for the AI features in your Python backend. rudderstack-ai wraps your OpenAI or Anthropic client, LangChain / LangGraph / Deep Agents runs, or Claude Agent SDK queries, and sends standard AI events (spec v0.2) through the RudderStack client you already use.
- One event per answer. However many model calls, agents or tool steps an answer takes, you get one
ai_response_receivedwith its totals: tokens, cost, latency, models, tool calls, status. - Joinable. The prompt, the answer and later feedback share an
interaction_id, and every event carriesconversation_id. Your app addsintentandoutcome, so you can build audiences such as "users whose refund questions went unresolved". - Never in the way. The original client is not modified, tracking errors are swallowed, and provider errors reach your code unchanged.
- Private by default. Prompt, answer and feedback text are sent only if you turn on content capture.
Server-side only: events are emitted where the model calls happen. The Node.js counterpart is @rudderstack/ai; both emit identical events.
Contents
- Install
- Quick start
- How it works
- Configuration
- Events
- Privacy and content capture
- Cost
- Adoption tag:
context.wrapperLibrary - Compatibility
- Limitations
- Examples
Install
Python 3.12 or later. Install the extra for each library you use:
pip install "rudderstack-ai[openai]" # or: uv add "rudderstack-ai[openai]"
pip install "rudderstack-ai[anthropic]"
pip install "rudderstack-ai[langchain]" # LangChain, LangGraph, Deep Agents
pip install "rudderstack-ai[claude-agent-sdk]"
pip install "rudderstack-ai[all]"
The package has no dependencies of its own. It sends events through your RudderStack Python SDK client (rudder-sdk-python) and never creates one. Both packages share the rudderstack namespace: rudderstack.analytics and rudderstack.ai.
Quick start
Configure once at startup with your existing client, either the module or a Client instance:
import rudderstack.analytics as rudder_analytics
import rudderstack.ai
rudder_analytics.write_key = "<WRITE_KEY>"
rudder_analytics.dataPlaneUrl = "<DATA_PLANE_URL>"
rudderstack.ai.configure(analytics=rudder_analytics)
OpenAI
chat.completions.create / parse / stream and responses.create / parse / stream, sync and async, streaming or not.
from openai import OpenAI
from rudderstack.ai import ai_context, ai_interaction
from rudderstack.ai.openai import wrap_openai
client = wrap_openai(OpenAI())
with ai_context(user_id=user.id, conversation_id=chat.id, feature="support_bot"):
with ai_interaction(intent="refund_request") as interaction:
reply = client.chat.completions.create(model="gpt-4.1-mini", messages=messages)
interaction.set_outcome("resolved")
return {"answer": reply.choices[0].message.content, "interaction_id": interaction.id}
AzureOpenAI and OpenAI-compatible endpoints work the same way; pass provider="azure.ai.openai" (or openrouter, vllm...) to label them.
Anthropic
messages.create (including stream=True), messages.parse and the messages.stream() helper, plus the same under beta.messages; sync and async.
from anthropic import Anthropic
from rudderstack.ai import ai_interaction
from rudderstack.ai.anthropic import wrap_anthropic
client = wrap_anthropic(Anthropic())
with ai_interaction(user_id=user.id, conversation_id=chat.id, feature="writer"):
with client.messages.stream(model="claude-haiku-4-5", max_tokens=1024, messages=messages) as stream:
for text in stream.text_stream:
print(text, end="")
For AnthropicBedrock or AnthropicVertex, pass provider="aws.bedrock" or provider="gcp.vertex_ai".
LangChain, LangGraph and Deep Agents
One callback handler. Subagent usage is attributed by agent name.
from rudderstack.ai.langchain import RudderStackCallbackHandler
handler = RudderStackCallbackHandler()
result = agent.invoke(
{"messages": messages},
config={
"callbacks": [handler],
"metadata": {"user_id": user.id, "conversation_id": chat.id, "intent": "refund_request"},
},
)
Without an active ai_interaction, the handler opens one per root run and reads identity from the run metadata (user_id, anonymous_id, conversation_id, interaction_id, feature, intent, trace_id). Inside an ai_interaction, it reports into that interaction instead.
Claude Agent SDK
tracked_query is a drop-in for claude_agent_sdk.query(). Cost and per-model usage come from the SDK's final ResultMessage.
from rudderstack.ai import ai_context
from rudderstack.ai.claude_agent_sdk import tracked_query
with ai_context(user_id=user.id, conversation_id=chat.id):
async for message in tracked_query(prompt=question, options=options, feature="ops_agent"):
...
With ClaudeSDKClient, wrap its response stream: async for message in tracked_messages(client.receive_response()): ..., or map a result yourself with record_claude_result(result_message).
Feedback
Return interaction.id with the answer, and send feedback from your server:
from rudderstack.ai import track_action
track_action(
"feedback_given",
interaction_id=body.interaction_id,
conversation_id=body.conversation_id,
user_id=user.id,
action_details={"feedback_type": "thumbs", "feedback_value": False},
)
Action types: feedback_given, copied_response, shared, reported, regenerated. conversation_id is required (from the argument or the current ai_context); without it the event is dropped with a warning.
How it works
ai_contextsets identity once per request:user_id,anonymous_id,conversation_id,feature, and optionally a client and extra trackcontext. It usescontextvars, so concurrent requests and asyncio tasks don't mix. Nested contexts inherit what they don't set.ai_interactionis one prompt and one answer. Every instrumented model call inside it is aggregated, andai_response_receivedis sent when the block exits. An exception leaving the block sets the status (error,timeoutorcancelled) and is re-raised unchanged.- Adapters (
wrap_openai,wrap_anthropic, the LangChain handler,tracked_query) only report calls into the active interaction. Calls are de-duplicated on the provider response ID, so a wrapped client used by LangChain with the handler attached is counted once. - Calls outside an interaction still produce a valid event of their own:
trigger: "user"when identity is in context, otherwisetrigger: "system"withanonymousId: "ai_system"and a mintedconversation_id. The SDK never invents ananonymousIdwhen auserIdis set: forward the browser SDK'sanonymousIdyourself to stitch profiles. No prompt event is sent for them. - The prompt event is held until the first model call starts (or the interaction ends), so it goes through the same client and carries the same integration tag as the answer. It keeps its original timestamp.
Streaming to the browser
When a handler returns a stream, the interaction must stay open until the stream ends. Pass the generator through interaction.bind():
from fastapi.responses import StreamingResponse
with ai_interaction(user_id=user.id, conversation_id=chat.id) as interaction:
async def tokens():
stream = await client.chat.completions.create(model="gpt-4.1-mini", messages=messages, stream=True)
async for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
body = interaction.bind(tokens())
return StreamingResponse(body, headers={"x-interaction-id": interaction.id})
A provider stream that is closed or abandoned before it completes counts as a cancelled call, so the answer is cancelled when that was the last call; a stream passed to bind() and closed early by the client ends the interaction as cancelled. For OpenAI chat streams, the wrapper asks for usage in the final chunk (stream_options.include_usage) and hides that extra chunk unless you requested it yourself.
Intent, outcome and trace ID
with ai_interaction(
user_id=u, conversation_id=c, intent="refund_request", trace_id=langfuse_trace_id
) as interaction:
...
interaction.set_intent("order_status") # known only later: goes on the response event
interaction.set_outcome("escalated")
interaction.set_trace_id(trace_id)
intent given at the start goes on the prompt and response events; outcome and trace_id go on ai_response_received. All are free-form strings. trace_id links the interaction to Langfuse, LangSmith or an OpenTelemetry trace.
Manual instrumentation
For a client without an adapter, report calls yourself:
with ai_interaction(user_id=u, conversation_id=c) as interaction:
result = my_llm.generate(prompt)
interaction.record_call(
provider="acme",
model="acme-large",
input_tokens=result.input_tokens,
output_tokens=result.output_tokens,
response_id=result.id,
finish_reason=result.stop_reason,
)
interaction.end(response_text=result.text) # optional: end early, with the answer (content capture only)
For frameworks with their own lifecycle hooks, start_interaction() creates an interaction without entering it; wrap work in interaction.activate() and call interaction.end().
Configuration
import rudderstack.ai
rudderstack.ai.configure(
analytics=rudder_analytics, # global fallback client
prices={
"gpt-4.1-mini": {"input_per_mtok": 0.25, "output_per_mtok": 2.00, "cached_input_per_mtok": 0.025}
},
capture_content=False, # prompt_text, response_text, feedback_text
emit_llm_calls=False, # also send one ai_llm_response_received per model call
)
configure() changes only the arguments you pass; prices are merged into the registered prices (call clear_prices() to start over). The prices above are placeholders: use your own rates.
| Option | Default | Description |
|---|---|---|
analytics |
None |
RudderStack client used when nothing more specific is set. |
prices |
none | Per-model prices in USD per 1M tokens, merged into the registry (see Cost). |
capture_content |
False |
Send prompt, response and feedback text. |
emit_llm_calls |
False |
Also send ai_llm_response_received for each model call. Totals belong on ai_response_received; don't sum both. |
Which client is used. ai_interaction(analytics=...) > ai_context(analytics=...) > the adapter's analytics= (wrap_openai(client, analytics=...), RudderStackCallbackHandler(analytics=...), tracked_query(analytics=...)) > configure(analytics=...). With no client anywhere, events are dropped with a debug log.
Logging. The package logs to the rudderstack.ai logger: warnings for dropped ai_user_action events, debug messages for swallowed tracking errors.
Serverless. The RudderStack client sends events from a background queue. In AWS Lambda and similar runtimes, call rudder_analytics.flush() before the handler returns.
API reference
| Name | Signature |
|---|---|
configure |
configure(*, analytics=..., prices=None, capture_content=..., emit_llm_calls=...) |
register_price |
register_price(model, *, input_per_mtok, output_per_mtok, cached_input_per_mtok=None, cache_write_per_mtok=None, provider=None) |
clear_prices |
clear_prices() |
ai_context |
ai_context(*, user_id=None, anonymous_id=None, conversation_id=None, feature=None, analytics=None, context=None) |
ai_interaction |
ai_interaction(*, user_id=None, anonymous_id=None, conversation_id=None, feature=None, intent=None, trace_id=None, input_method=None, prompt_text=None, interaction_id=None, analytics=None, capture_content=None, context=None) |
start_interaction |
start_interaction(*, input_method=None, prompt_text=None, emit_prompt=True, trigger="user", **interaction_fields) |
Interaction |
.id, .set_intent(), .set_outcome(), .set_trace_id(), .set_primary_model(), .record_call(), .record_tool_call(), .bind(), .activate(), .end() |
track_action |
track_action(action_type, *, interaction_id, conversation_id=None, action_details=None, user_id=None, anonymous_id=None, analytics=None, context=None) |
current_context, current_interaction |
the active AIContext / Interaction, or None |
unwrap |
unwrap(client): the original client of a wrapped one |
rudderstack.ai.openai.wrap_openai |
wrap_openai(client, *, analytics=None, provider="openai") |
rudderstack.ai.anthropic.wrap_anthropic |
wrap_anthropic(client, *, analytics=None, provider="anthropic") |
rudderstack.ai.langchain.RudderStackCallbackHandler |
RudderStackCallbackHandler(*, analytics=None, emit_prompt=True, trigger="user", agent_name=None) |
rudderstack.ai.claude_agent_sdk.tracked_query |
tracked_query(*, prompt, options=None, transport=None, analytics=None, input_method=None, user_id=None, anonymous_id=None, conversation_id=None, feature=None, intent=None, trace_id=None) |
rudderstack.ai.claude_agent_sdk.tracked_messages |
tracked_messages(messages, *, analytics=None, prompt_text=None, ...): same, for any message stream |
rudderstack.ai.claude_agent_sdk.record_claude_result |
record_claude_result(result, interaction=None, *, calls_by_model=None, primary_model=None, end=True, **interaction_fields) |
Every public function and class has a docstring with the details; the package ships type hints (py.typed).
Events
All four events are track calls. Every event has conversation_id and interaction_id, plus feature when set; the spec version travels in context.wrapperLibrary.specVersion. IDs the SDK mints (interaction_id, and conversation_id when you don't give one) are UUID v4 strings; IDs you pass are used as given. The JSON Schema is in spec/ai-events-v0.2.schema.json; use it in a tracking plan.
ai_user_prompt_created
{
"conversation_id": "7d1f3c2a-9b4e-4c8d-a6f1-2e5b8c9d0a3f",
"interaction_id": "0b6e2f4a-3c1d-4e7f-9a8b-5c6d7e8f9a0b",
"feature": "support_chat",
"input_method": "text",
"intent": "refund_status"
}
Optional: input_method, intent, prompt_text (content capture only).
ai_response_received
One per answer: an OpenAI tool loop with two model calls, priced with a registered price:
{
"conversation_id": "7d1f3c2a-9b4e-4c8d-a6f1-2e5b8c9d0a3f",
"interaction_id": "0b6e2f4a-3c1d-4e7f-9a8b-5c6d7e8f9a0b",
"feature": "support_chat",
"response_status": "success",
"trigger": "user",
"primary_model": "gpt-4.1-mini",
"primary_provider": "openai",
"latency_ms": 1840,
"llm_call_count": 2,
"tool_call_count": 1,
"total_input_tokens": 60,
"total_output_tokens": 24,
"total_tokens": 84,
"total_cached_input_tokens": 0,
"total_cost_usd": 0.000063,
"finish_reason": "stop",
"intent": "refund_status",
"outcome": "resolved",
"usage_breakdown": [
{
"provider": "openai",
"model": "gpt-4.1-mini",
"call_count": 2,
"input_tokens": 60,
"output_tokens": 24,
"cached_input_tokens": 0,
"total_tokens": 84,
"cost_usd": 0.000063
}
],
"trace_id": "lf-trace-91b2"
}
| Property | Notes |
|---|---|
response_status |
success, error, timeout or cancelled. The last call decides: a failed call followed by a successful retry is success; a last call that failed is error, or timeout / cancelled when it timed out or was cancelled (including a stream closed before it completed). |
error_type |
On non-success: rate_limit (every HTTP 429, including OpenAI's insufficient_quota), timeout (also HTTP 408 and 504), cancelled, auth_error, invalid_request, connection_error, api_error, or a framework value (e.g. max_turns). A failed OpenAI Responses object (status: failed) is api_error. |
trigger |
user, or system for calls without identity. |
primary_model, primary_provider |
The model set with set_primary_model() (until a later successful call), else the model of the last successful call (the main agent's model for the Claude Agent SDK); when no call succeeded, the model of the last attempted call. Providers use OTel gen_ai.provider.name values. |
latency_ms, time_to_first_token_ms |
From the start of the interaction. |
llm_call_count, tool_call_count |
tool_call_count counts every tool the model invoked, provider-hosted ones included: Anthropic tool_use, server_tool_use and mcp_tool_use blocks; OpenAI function calls and Responses built-in tool calls (web_search_call, file_search_call, code_interpreter_call, mcp_call...). A tool call reported by both a provider wrapper and the LangChain handler counts once (matched by its tool call ID). |
total_input_tokens |
Includes cached and cache-write tokens, for every provider. |
total_cached_input_tokens |
Cache reads. |
total_cost_usd |
Only when every call was priced. |
usage_breakdown |
One entry per agent, provider and model, with call_count and total_tokens. Entries sum to the totals. agent_name appears for agent frameworks. |
intent, outcome, trace_id |
Set by your app. |
response_text |
Content capture only. |
| custom | interaction.end(extra_properties=...) adds your own properties; SDK-owned names (every property above, plus prompt_text / input_method) are ignored. |
ai_user_action
{
"conversation_id": "7d1f3c2a-9b4e-4c8d-a6f1-2e5b8c9d0a3f",
"interaction_id": "0b6e2f4a-3c1d-4e7f-9a8b-5c6d7e8f9a0b",
"action_type": "feedback_given",
"action_details": {"feedback_type": "thumbs", "feedback_value": true}
}
action_details.feedback_type and feedback_text must be strings; a value of another type is dropped with a warning.
ai_llm_response_received (optional)
Off by default (emit_llm_calls=True). One per model call, for debugging:
{
"conversation_id": "7d1f3c2a-9b4e-4c8d-a6f1-2e5b8c9d0a3f",
"interaction_id": "0b6e2f4a-3c1d-4e7f-9a8b-5c6d7e8f9a0b",
"feature": "support_chat",
"provider": "openai",
"model": "gpt-4.1-mini",
"input_tokens": 25,
"output_tokens": 12,
"total_tokens": 37,
"cached_input_tokens": 0,
"cost_usd": 0.00003025,
"response_id": "chatcmpl-9x2...",
"finish_reason": "tool_calls"
}
The Claude Agent SDK reports usage per model, not per call, so there it sends one per model.
Privacy and content capture
By default no free text leaves your server: prompt_text, response_text and action_details.feedback_text are stripped. configure(capture_content=True) turns all three on; ai_interaction(capture_content=True) turns on prompt and response text for that interaction only (track_action follows the active interaction's setting when it runs inside one, else the global one). Treat captured text as personal data. (The examples turn it on so you can see the fields.)
Where the text comes from: prompt_text from ai_interaction(prompt_text=...) or the tracked_query prompt; response_text from interaction.end(response_text=...) or the Claude Agent SDK result (successful runs only); feedback_text from track_action(action_details=...).
Cost
No price table ships with the package: list prices change and many teams pay negotiated rates. Cost is resolved in this order:
- Framework-reported. The Claude Agent SDK's
total_cost_usd/ per-modelcostUSD. - Your prices.
register_price()orconfigure(prices=...)(merged;clear_prices()empties the registry), in USD per 1M tokens. A name matches exactly or by longest prefix, sogpt-4.1-minialso pricesgpt-4.1-mini-2025-04-14. A price registered withprovider=wins over one for any provider. - None. Events carry tokens only.
register_price raises ValueError for an empty model name or a negative, infinite or NaN rate.
from rudderstack.ai import register_price
register_price(
"claude-haiku-4-5",
input_per_mtok=1.00,
output_per_mtok=5.00, # your rates
cached_input_per_mtok=0.10,
cache_write_per_mtok=1.25,
provider="anthropic",
)
Cached input defaults to the input rate when cached_input_per_mtok is not given; the same goes for cache writes. Failed calls count as $0. A cancelled stream's usage is at best partial, so that call is left unpriced, which leaves total_cost_usd out rather than understating it.
Adoption tag: context.wrapperLibrary
Every event carries context.wrapperLibrary beside the core SDK's context.library, so RudderStack can see which integration produced it:
{
"library": {"name": "analytics-python", "version": "2.1.9"},
"wrapperLibrary": {"name": "rudderstack-ai", "version": "0.1.0", "integration": "openai", "specVersion": "0.2.0"}
}
integration is the first adapter that reported into the interaction: openai, anthropic, langchain, claude-agent-sdk, or manual (track_action, or an interaction with only hand-recorded calls). If a library built on top of this one sets its own tag with context={"wrapperLibrary": {...}} (on ai_context, ai_interaction or track_action), it is nested inside ours as wrapperLibrary.wrapperLibrary; other keys of that context are passed through. A context given to ai_interaction or track_action replaces the ai_context one for those events.
Compatibility
| Supported | Tested in CI | |
|---|---|---|
| Python | 3.12+ | 3.12, 3.13, 3.14 |
openai |
>=2.0.0,<4 |
2.0.0 (lowest), latest 3.x |
anthropic |
>=1.0.0,<2 |
1.0.0 (lowest), latest 1.x |
langchain-core |
>=1.2.0,<2 |
1.2.0 (lowest), latest 1.x; LangGraph 1.x and Deep Agents 0.7 with the latest |
claude-agent-sdk |
>=0.2.82,<0.3 |
0.2.82 (lowest), latest 0.2.x |
rudder-sdk-python |
2.x (any object with its track() signature) |
2.1.9 |
CI runs the test suite with the lowest and the highest versions each range allows.
Limitations
- Not tracked: OpenAI
responses.create(background=True)(only queued at that point; no outcome or usage yet), OpenAI and Anthropicwith_raw_response/with_streaming_response, and Anthropic'sbeta.messages.tool_runner()(it calls the API through the unwrapped client). Useai_interaction+record_callfor those. - The
.stream()helpers are observed by swapping the helper's private raw stream. If a future SDK renames it, the call still works but goes untracked; CI's latest-versions job catches this. - An async stream abandoned with
breakoutside any interaction is reported when it is closed or garbage-collected. Inside an interaction it is closed out as a cancelled call when the interaction ends. Close async streams (await stream.close()orasync with) to release the connection. - A stream passed to
interaction.bind()must be consumed, or the response event is never sent. - The LangChain handler's own interactions (opened from run
metadata) are not visible towrap_openai/wrap_anthropicclients called inside the run, since callbacks can't set the caller's context. When you combine a wrapped client with the handler, wrap the run inai_interactionso both report into one interaction. - A wrapped client is a proxy: attribute access,
with_options()and type hints behave like the original, butisinstance(wrapped, OpenAI)isFalse. Passunwrap(wrapped)to code that checks the class. - MCP traffic is out of scope.
Examples
examples/ has a runnable app per integration and a full-stack chat app (FastAPI, Anthropic and the RudderStack JavaScript SDK). Each runs against the real APIs with your keys, or offline against the bundled mock server.
Contributing and security
See CONTRIBUTING.md and SECURITY.md. Releases: RELEASING.md.
License
Metadata
Release files for rudderstack-ai 0.1.1
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| rudderstack_ai-0.1.1.tar.gz | 47.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| rudderstack_ai-0.1.1-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 92.8 kB
Release files / rudderstack_ai-0.1.1.tar.gz
| Download URL | rudderstack_ai-0.1.1.tar.gz |
|---|---|
| Size | 47.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
84e8afe451f4979ead96967973b3b97e7d11dd61c6a489138b8310f00f227cc0
|
|
BLAKE2b-256 checksum How to use checksums |
64836e56e357b1a01bbe25421f8f270550f2735fa52129702e7ef69075ae2c46
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency logRelease files / rudderstack_ai-0.1.1-py3-none-any.whl
| Download URL | rudderstack_ai-0.1.1-py3-none-any.whl |
|---|---|
| Size | 45.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0ebb3e995717719a4ad31a672acd00aee1d0b8542f2a2bb85f5ea36cd65e8b8c
|
|
BLAKE2b-256 checksum How to use checksums |
1bc8336e1c0eac94afa2f889663817d660aff4b0e6ea12b7bbf8ac43dafacefc
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.
Transparency log