rudderstack-ai
Product analytics for the AI features of a Python backend. rudderstack-ai observes your OpenAI, OpenAI Agents SDK, Anthropic, Google Gen AI, LangChain / LangGraph or Claude Agent SDK calls and sends the standard AI events (spec v0.2) through the RudderStack client your app already uses. The Node.js package is @rudderstack/ai; both send the same events, pinned by the shared fixtures in spec/fixtures.
How it fits
your request handler ── ai_interaction ── wrapped client / LangChain handler / tracked_query
│
└─ your rudder-sdk-python client ─▶ RudderStack ─▶ warehouse, destinations, audiences
- One event per answer. However many model calls, agents and tool steps an answer takes, you get one
ai_response_receivedwith its totals: tokens, cost, latency, models, tool calls, status. The prompt, the answer and later feedback share aninteraction_id; every event carriesconversation_id. - Product analytics, not debugging. The events land next to your other product events, keyed to the same
userId, so you can build funnels, retention and audiences ("users whose refund questions went unresolved"). It complements tracing tools (Langfuse, LangSmith, Braintrust, APM): settrace_idto join the two, or use the optional tracing module to send OpenTelemetry spans through the same pipe. - Private by default. Events carry IDs and metrics. Prompt, answer and feedback text are sent only when you turn on content capture, and are then masked.
- Never in the way. Clients are wrapped, not patched; the SDK never throws into your LLM call or
flush()(its own errors are logged), and provider errors reach your code unchanged. Delivery errors from the RudderStack client are handled by that client: pass itson_errorcallback to observe them (an exception raised from the client's own consumer thread is not the SDK's to catch). Server-side only.
Install
Python 3.12 or later. Install the extra for each library you use: openai, anthropic, google-genai (Gemini API or Vertex AI), bedrock (boto3 Bedrock Runtime), langchain (LangChain, LangGraph, Deep Agents), openai-agents (OpenAI Agents SDK), claude-agent-sdk, litellm (LiteLLM SDK), and tracing for OpenTelemetry spans (rudderstack.ai.tracing).
pip install "rudderstack-ai[openai]" # or: uv add "rudderstack-ai[openai]"
The package has no dependencies of its own. It sends events through your rudder-sdk-python client (rudderstack.analytics) and never creates one.
Quick start
import rudderstack.analytics as rudder_analytics
from openai import OpenAI
import rudderstack.ai
from rudderstack.ai import ai_interaction, track_action
from rudderstack.ai.openai import wrap_openai
rudder_analytics.write_key = "<WRITE_KEY>"
rudder_analytics.dataPlaneUrl = "<DATA_PLANE_URL>"
rudderstack.ai.configure(analytics=rudder_analytics) # once, at startup
client = wrap_openai(OpenAI())
def answer(user_id: str, chat_id: str, question: str) -> dict[str, str | None]:
with ai_interaction(user_id=user_id, conversation_id=chat_id, feature="support_bot") as interaction:
reply = client.chat.completions.create(
model="gpt-4.1-mini", messages=[{"role": "user", "content": question}]
)
interaction.set_outcome("resolved")
return {"answer": reply.choices[0].message.content, "interaction_id": interaction.id}
def feedback(user_id: str, chat_id: str, interaction_id: str, thumbs_up: bool) -> None:
track_action(
"feedback_given",
interaction_id=interaction_id,
conversation_id=chat_id,
user_id=user_id,
action_details={"feedback_type": "thumbs", "feedback_value": thumbs_up},
)
This sends ai_user_prompt_created and ai_response_received for the answer and ai_user_action for the feedback. In a script, worker or serverless handler, call rudderstack.ai.flush() before exiting (Serverless and workers). examples/ has a runnable app per integration, a background agent, three tracing apps 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.
Choose your integration
Use one integration per call path. Everything inside one ai_interaction becomes one answer, and a call reported by two integrations (a wrapped client used by LangChain, a wrapper plus a tracing span) is counted once.
Rules that apply to every chat app:
- Cost precedence: use the provider or gateway's reported cost first, then a registered model price; omit cost when neither is known.
- One
ai_interactionper user turn, not per conversation. Pass your chat thread's ID asconversation_id. Resending the history on every turn is fine: usage is counted per model call, not from the history. - Streaming responses: a route that returns a stream must keep the interaction open until the stream ends. Pass the generator through
interaction.bind()(example). - Thread pools:
ThreadPoolExecutorandloop.run_in_executordo not copy context; wrap the function withrudderstack.ai.propagate(fn)(asyncio.to_threadcopies it). - A call outside any interaction still sends its own
ai_response_received(trigger: userwhen identity is in context, elsesystem), but no prompt event and no classification.
What each integration tracks and its edge cases: docs/integrations.md.
OpenAI
from openai import AsyncOpenAI
from rudderstack.ai.openai import wrap_openai
# AzureOpenAI or a compatible endpoint: provider="azure.ai.openai", "openrouter"...
client = wrap_openai(AsyncOpenAI())
- Streamed chat calls report usage only when the response includes it. The wrapper asks for it (
stream_options.include_usage) on every host, including compatible endpoints (Azure, Groq, OpenRouter, Ollama, vLLM), and hides the extra chunk unless you asked for it. A request the host rejects (HTTP 400 or 422 naming the parameter, or with no error code) is sent once more as you wrote it, so a working call never breaks; once a host names the parameter, that client and itswith_options()copies stop adding it there. Other rejections (a content filter, a prompt too long) are not resent.stream_usage=Truealways adds it (no resend),Falsenever does. finish_reasonuses Chat Completions' terms on both APIs: a Responses output with only tool calls istool_calls,incompleteatmax_output_tokensislength, a refusal (message.refusal, a Responsesrefusalpart) isrefusal; a streamed Chat Completions refusal keeps the wire'sstop. Refusals and content filters are visible throughfinish_reason, not through status: a completed call isresponse_status: successeven when the model refused, was filtered, or was cut off atlength(structured output too), and it keeps its tokens and cost. Only an exception your code gets fails the answer:chat.completions.parse()raisingLengthFinishReasonError/ContentFilterFinishReasonErrorgivesresponse_status: errorwitherror_typelength/content_filter;responses.parse()raising on truncated structured output is a failed call (see Compatibility).- The wrapper is a proxy:
isinstance(client, OpenAI)isFalse. Passrudderstack.ai.unwrap(client)to code that checks the class.
OpenAI Agents SDK
from agents import SQLiteSession
from rudderstack.ai.openai_agents import tracked_run, tracked_run_streamed
result = await tracked_run(
triage_agent,
question,
tracking={"user_id": user_id, "feature": "support_agent"},
session=SQLiteSession(chat_id),
)
stream = tracked_run_streamed(triage_agent, question, tracking={"user_id": user_id})
async for event in stream.stream_events():
render(event)
tracked_run(agent, input, *, tracking=None, **run_kwargs) runs Runner.run (tracked_run_sync: Runner.run_sync) and reports the whole run, across handoffs, as one interaction; inside ai_interaction (or with tracking["interaction"]) it reports into that one instead. run_kwargs go to the Runner unchanged, and your hooks still run. tracking takes the interaction options plus analytics and provider (the label for every call, default openai, for OpenAI-compatible endpoints).
- Model calls: each model response is one call, attributed to the agent that made it (
agent_nameinusage_breakdown), with the agent's configured model name, input, output, reasoning (when the provider reports them) and cached tokens, andfinish_reason: stopfor a completed response. A response thatwrap_openaialready recorded (the same provider response ID) is counted once and gains the agent name. - Tools: every tool the model requested is a tool call: function tools, hosted tools (
web_search) and handoffs (transfer_to_<agent>). A local function tool also reports its duration and outcome; the Agents SDK's default failure output ("An error occurred while running the tool.") counts as a failure. Hosted tools and handoffs have no known outcome. - Agents used as tools: the nested run's model calls are recorded from Agents SDK trace spans and attributed to the nested agent, with the model name the provider reported. This needs Agents SDK tracing (on by default;
RunConfig(tracing_disabled=True)orOPENAI_AGENTS_DISABLE_TRACINGturn it off, and then only the top-level calls are counted). The nested run's own tools are not tracked. The helpers register a process-wide trace processor once and leave exporters alone; if you replace processors withset_trace_processors, includeregister_openai_agents_tracing()'s processor. - Failures: a guardrail tripwire ends the run with
response_status: erroranderror_type: guardrail_trip. A provider error fails the call in flight (attributed to the running agent) and the run; calls before it keep their usage. - Conversation: tracking
conversation_id, then the Runner'sconversation_id, then the session's ID, then a generated ID. - Streaming: the interaction ends when
stream_events()finishes or fails, or when you stop consuming it or callcancel()(cancelled). - Classification: interactions these helpers open are not classified unless you pass
classify=Trueintracking.
Gateways and routers (OpenRouter, a LiteLLM proxy) go through the same wrapper: wrap_openai(OpenAI(base_url=...), provider="openrouter"). Their own cost wins over a registered price (usage.cost, LiteLLM's x-litellm-response-cost); a vendor-prefixed model (openai/gpt-4o) is priced by a price registered for gpt-4o and reported under its vendor (openai), with the full name in model; requested_model names the model you asked for when a router or fallback served another one. Details: docs/integrations.md. See the OpenRouter example.
LiteLLM
import litellm
from rudderstack.ai.litellm import instrument_litellm
instrument_litellm() # once at startup
reply = litellm.completion(
model="anthropic/claude-haiku-4-5", messages=messages, fallbacks=["gemini/gemini-3.5-flash-lite"]
)
Tracks litellm.completion / acompletion (sync, async, streamed), and so the frameworks built on LiteLLM (CrewAI, DSPy, smolagents). A call with fallbacks= is one call, from the model that answered. Cost is LiteLLM's response_cost, then a registered price. LiteLLM 1.83.1 and later require openai<3, so the litellm extra (>=1.88.6) cannot be installed alongside openai 3; that constraint is LiteLLM's. See docs/integrations.md and the fallback example. The Node.js SDK tracks LiteLLM through its proxy (wrapOpenAI), not an SDK integration; the Vercel AI Gateway is JavaScript-only (Node.js SDK).
Anthropic
from anthropic import Anthropic
from rudderstack.ai.anthropic import wrap_anthropic
# AnthropicBedrock: provider="aws.bedrock"; AnthropicVertex: "gcp.vertex_ai"
client = wrap_anthropic(Anthropic())
Google Gen AI (Gemini and Vertex AI)
from google import genai
from rudderstack.ai.google_genai import wrap_google_genai
client = wrap_google_genai(genai.Client(api_key=os.environ["GEMINI_API_KEY"]))
vertex = wrap_google_genai(genai.Client(vertexai=True, project="my-project", location="us-central1"))
Amazon Bedrock (Converse)
import boto3
from rudderstack.ai.bedrock import wrap_bedrock
client = wrap_bedrock(boto3.client("bedrock-runtime", region_name="us-east-1"))
LangChain and LangGraph
One callback handler covers LangChain, LangGraph and Deep Agents. Pass it in the run's config, not to a model's constructor (a handler that sees only model runs makes each model call its own answer, and logs a warning).
from rudderstack.ai.langchain import RudderStackCallbackHandler
handler = RudderStackCallbackHandler()
result = graph.invoke(
{"messages": [("user", question)]},
config={
"callbacks": [handler],
"configurable": {"thread_id": chat_id},
"metadata": {"user_id": user_id, "feature": "support_agent"},
},
)
- Outside
ai_interaction, the handler opens one interaction per root run (invoke/stream) with identity frommetadata(user_id,anonymous_id,conversation_id,interaction_id,feature,intent,trace_id). Withoutconversation_id, LangGraph'sthread_idis the conversation. - With a checkpointer, each
invokeis one turn; resuming an interrupted graph (Command(resume=...)) opens a new interaction without a prompt event. - A graph that stops at
interrupt()ends its answer withresponse_status: paused(insideai_interactiontoo, unless a later call answers it), so nothing is left open while it waits, even across processes. To report the resume as the same turn, pass the sameinteraction_idin the metadata of both runs, for example one derived from thethread_id;Sendbranches and subgraphs stay in that interaction. See examples/langgraph-checkpoint. - A wrapped OpenAI or Anthropic client called directly from a graph node cannot see the handler's interaction. Wrap the run in
ai_interactionto join them (that also turns on classification, which handler-opened interactions skip). usage_breakdown[].agent_namecomes from theagent_name/lc_agent_namemetadata (LangChain agents and Deep Agents subagents set it) or anagent:<name>tag. Providers are reported with their OpenTelemetry names (aws.bedrock,gcp.vertex_ai,azure.ai.openai...). See Deep Agents across Claude and Gemini.- A model run's usage is summed over its prompts. A stream the consumer stops or a cancelled run is a cancelled call that keeps the tokens streamed so far (summed over chunks, as LangChain merges them), unpriced.
Claude Agent SDK
tracked_query is a drop-in for claude_agent_sdk.query(). Cost, tokens and per-model usage come from the SDK's ResultMessage.
from claude_agent_sdk import ClaudeAgentOptions, ResultMessage
from rudderstack.ai.claude_agent_sdk import tracked_query, usage_snapshot
async for message in tracked_query(
prompt=question,
options=ClaudeAgentOptions(resume=session.id), # resume=None on the first turn
usage_baseline=session.usage, # None on the first turn
user_id=user_id,
):
if isinstance(message, ResultMessage):
session.usage = usage_snapshot(message) # JSON-serialisable: store it with the session
Resumed and continued sessions. On resume / continue_conversation, on later turns of a ClaudeSDKClient and on later results of a streaming-input query(), the SDK reports usage and cost for the whole session so far. The SDK subtracts the previous result it saw in this process; across processes, store usage_snapshot() and pass it as the next turn's usage_baseline. Without either, a resumed turn counts its own tokens and prices them with your registered prices (no cost when none is registered). Turns it opens are classified only with classify=True.
Tracing only
If your model calls are already instrumented with OpenTelemetry (OpenLLMetry, OpenInference, the official GenAI instrumentations), register_tracing(product_events=True) builds the same product events from the spans, without a provider wrapper. You still open the interaction. See Tracing.
Cloud model IDs and pricing
No prices ship with the SDK, and cloud providers name models their own way. A registered base-model price also covers:
- Bedrock model IDs (provider
aws.bedrockonly): a leading cross-region inference profile (us.,us-gov.,eu.,apac.,jp.,au.,global.) and then the vendor prefix (anthropic.,amazon.,meta....) are stripped for the lookup, soclaude-sonnet-4-5pricesus.anthropic.claude-sonnet-4-5-20250929-v1:0andnova-propricesamazon.nova-pro-v1:0. - Vertex AI revisions:
claude-sonnet-4-5@20250929matchesclaude-sonnet-4-5through the usual version-suffix rule.
Azure OpenAI deployment names are yours to choose, so they are never guessed. Map them:
from rudderstack.ai import configure, register_price
register_price("gpt-4o", input_per_mtok=2.5, output_per_mtok=10)
configure(azure_deployment_models={"support-prod": "gpt-4o"})
The mapping applies to calls with provider azure.ai.openai and only to the price lookup: events keep the deployment name. Each call replaces the previous mapping (None or {} clears it); clear_prices() leaves it in place. The suffix rule still never prices another model of the family: claude-sonnet-4-5 does not price anthropic.claude-sonnet-4-5-mini-v1:0.
Other frameworks
For a client without an integration, report each call:
from rudderstack.ai import ai_interaction
with ai_interaction(user_id=user_id, conversation_id=chat_id) as interaction:
result = my_llm.generate(prompt)
interaction.record_call(
provider="acme",
model="acme-large",
input_tokens=result.input_tokens, # including cached tokens
output_tokens=result.output_tokens,
response_id=result.id, # de-duplicates a call reported twice
)
For frameworks with their own lifecycle hooks, start_interaction() creates an interaction without entering it: run the work in with interaction.activate(): and call interaction.end().
Identity
Events need a userId or an anonymousId. Pass them explicitly, set them once per request with with ai_context(user_id=..., conversation_id=..., feature=...): (every interaction, wrapped call and track_action inside uses them), or let the request middleware read them from the RudderStack JavaScript SDK's cookies. ai_context uses contextvars, so concurrent requests and asyncio tasks never mix. Nested contexts inherit what they do not set.
Request middleware
When the page runs the RudderStack JavaScript SDK (v3), its cookies arrive with every same-origin request. The middleware wraps each request in ai_context(request=...), so events carry the browser's anonymousId and session without any identity plumbing. When the page does not run the JavaScript SDK (or for API-only clients), the cookies are absent and the middleware does nothing.
| Framework | Setup |
|---|---|
| FastAPI / Starlette (any ASGI app, HTTP and WebSocket) | app.add_middleware(AIContextMiddleware); call configure() in the app's lifespan or at import |
| Django (sync and async) | "rudderstack.ai.middleware.DjangoAIContextMiddleware" in MIDDLEWARE; call configure() in an AppConfig.ready() |
| Flask | init_flask(app) in the app factory, after configure() |
All three are in rudderstack.ai.middleware, which imports no framework. Without middleware, pass the request (Starlette, Django, Flask/Werkzeug, an ASGI scope, a WSGI environ or a header mapping) or the cookies (a Cookie header or a name-to-value map, e.g. from a queue message) yourself: ai_interaction(request=request) or ai_interaction(cookies={"rl_anonymous_id": anonymous_id}).
| Cookie | Becomes | Rule |
|---|---|---|
rl_anonymous_id |
anonymousId |
unless set explicitly |
rl_session |
context.sessionId |
while the session is active; never sessionStart |
rl_user_id |
userId |
fallback only: ignored when a user_id or anonymous_id is set explicitly or in ai_context |
Precedence: arguments of the interaction (or track_action), then ai_context values, then cookies. A context={"sessionId": ...} you set wins too. Only these three cookies are read; unreadable cookies are skipped. A WebSocket keeps its handshake's cookies for its whole life.
Stale rl_user_id after logout. The JavaScript SDK keeps rl_user_id until the page calls rudderanalytics.reset(), so on a shared browser it can name the previous user. Pass the server's own user_id (or an anonymous_id for a logged-out visitor) whenever you know it, call reset() on logout, or turn the fallback off with configure(identity_from_cookies=False) (per interaction: identity_from_cookies=False).
Legacy v1.1 cookies (RudderEncrypt:) are not supported; upgrade the browser SDK to v3.
Background agents
Use ai_task for LLM work no user is waiting on: a scheduled job, a webhook or queue worker, a Slack bot, a sub-agent. It is a system-triggered interaction: no prompt event, one ai_response_received per run.
from rudderstack.ai import ai_context, ai_task
with ai_context(account_id=workspace.id):
with ai_task("alert-triage", on_behalf_of=alert.owner_id, trigger_source="schedule") as run:
summary = summarise(alert) # wrapped clients report into the run
run.set_outcome("resolved")
It works as with, async with or a decorator (@ai_task("nightly-digest"), sync or async; each call is its own run).
- Actor vs beneficiary. The actor is the system:
userIdstays unset (anonymousId: ai_system), and the request's identity and cookies are never used, so machine activity never counts as a person's. Put the customer inaccount_idand the person the work is for inon_behalf_of. For multi-tenant bots and cron jobs, also pass a stable workspace-specificanonymous_idand explicitconversation_id; otherwise the sharedai_systemID merges unrelated machine traffic in identity-based analytics. Spans inside a task never carry the person's identity either. - Chaining. Inside another interaction or task, a task is chained to it:
parent_interaction_idandconversation_idare the current interaction's, andtrigger_sourcedefaults toagent. Passparent_interaction_id="..."to link a run started elsewhere (e.g. from a queue message), orNoneto start a new chain. - Flush. A task that is not chained to a running interaction flushes on exit (
async withawaits it), so a cron job or Lambda delivers its events before it stops. Passflush=Falsein a long-lived worker and flush at shutdown;flush=Truemakes a chained task flush too. - Classification is off for tasks;
ai_task(..., classify=True)turns it on, and the task waits for the label before flushing (inside an event loop, useasync withfor that).
Product signals
Feedback
Return interaction.id with the answer, then report what the user did with it through track_action() (quick start). Action types: feedback_given, feedback_retracted, copied_response, shared, reported, regenerated. feedback_retracted clears the interaction's feedback, so the latest feedback_given / feedback_retracted per interaction_id is the final state. conversation_id is required (argument or ai_context); without it the event is dropped with a warning. feedback_text is sent only with content capture on.
Intent classification
Opt-in. After each answer, your own model client labels the interaction with one of your intents and an outcome, sent as one ai_interaction_classified. Nothing waits for it.
import anthropic
rudderstack.ai.configure(
classifier={
"client": anthropic.Anthropic(), # or AsyncAnthropic, OpenAI, AsyncOpenAI: your key, your provider
"model": "claude-haiku-4-5",
"intents": ["refund_request", "order_status", "bug_report"],
"outcomes": ["resolved", "partial", "unresolved", "unclear"], # optional; this is the default
"intent_summary": False, # optional: a one-sentence masked summary
"timeout_ms": 5000, # optional
}
)
# Or your own function (sync or async; it receives a rudderstack.ai.Turn): {"classify": fn, ...}
One label per interaction you open (ai_interaction, start_interaction) that succeeded; errors and cancelled answers are skipped. Interactions an integration opens on its own are not classified: a wrapped call outside any interaction, a LangChain run outside one, a tracked_query turn outside one. To label them, wrap the request in ai_interaction, or pass classify=True to tracked_query / tracked_messages / record_claude_result. Tasks: ai_task(..., classify=True). Opt one interaction out with ai_interaction(classify=False). flush() / aflush() wait for pending classifications before flushing. Labels, input, cost, async clients, failures and limits: docs/events.md.
Declared tool events
Rules turn tool calls into your own product events (save_memory → memory_captured), next to the standard events:
rudderstack.ai.configure(
event_rules={
"tools": {
"save_memory": "memory_captured",
"create_ticket": rudderstack.ai.ToolRule(
event="ticket_created",
properties=lambda args, result: {"priority": args.get("priority")},
when=lambda args, result: result is not None,
),
}
}
)
A rule is an event name or a ToolRule (or a mapping with event, properties, when). args is what the model passed (decoded JSON), result what the tool returned or your app sent back to the model, None when unseen. Each call sends its event once, when its result is known or when the interaction ends (tool_status: unknown). Wrap the whole tool loop in one ai_interaction to get results. Names starting with ai_ are reserved; a malformed rule raises at configure().
Tool breakdown
configure(tool_breakdown=True) (or per run: ai_interaction(tool_breakdown=True)) adds one entry per agent and tool identity to ai_response_received. Entries include measured-only duration totals, maxima and coverage, fixed error categories, MCP server/tool splits and nested parent_tool where integrations expose them. Tool names only, never inputs or outputs. Details: docs/events.md.
Multi-agent
usage_breakdown has one entry per agent, provider, served model and differing requested model, and tool_breakdown one per agent, MCP server, tool and parent tool, so the same model or tool used by several agents stays apart. Agents are named by:
- LangChain / LangGraph:
agent_name/lc_agent_namerun metadata (Deep Agents subagents set it), anagent:<name>tag, the parent run's agent, the handler'sagent_name, else the root run's name (a LangGraph graph or Deep Agents agent). - Claude Agent SDK: a subagent's calls and tools take the
subagent_typeof theTask/Agentcall that launched it, and its tools carry that call's name asparent_tool; main-loop calls have noagent_name. Each model's tokens and cost still add up to the SDK'smodelUsage: a subagent's answer, which is not streamed, counts from its tool result's usage; when the streamed messages still fall short, the main loop keeps the result's own usage and the subagents share the rest, and a model's cost is split between its agents by Claude's price ratios. record_callandrecord_tool_call: theagent_nameyou pass.
Every usage_breakdown entry has cache_write_tokens (summed in total_cache_write_tokens) and error_count (failed calls, retries included). Nested ai_tasks report separately; roll a run tree up by parent_interaction_id (query).
Privacy
Sent by default: IDs (userId, anonymousId, conversation_id, interaction_id), the fields you set (feature, intent, outcome, run fields), models, providers, token counts, cost, latency, status, error categories, tool names and counts. Never sent: prompt, answer and feedback text (unless content capture is on), tool arguments and results, request headers, any cookie other than the three above, traits or emails.
Content capture. configure(capture_content=True) adds prompt_text, response_text and action_details.feedback_text; ai_interaction(capture_content=True) turns it on for one interaction. prompt_text comes from ai_interaction(prompt_text=...) or the tracked_query prompt; response_text from interaction.end(response_text=...) or the Claude Agent SDK result. Treat captured text as personal data.
Masking is on by default for all captured text, the classifier's intent_summary and captured span content:
| Pattern | Replaced by |
|---|---|
| Email addresses | [email] |
Phone numbers (international + form, US shapes, 98765 43210) |
[phone] |
| Payment card numbers (Luhn-valid) | [card] |
| IPv4 and IPv6 addresses | [ip] |
| US Social Security numbers | [ssn] |
| IBANs (checksum-valid) | [iban] |
API keys and tokens (sk-, sk_live_, rk_live_, AIza, xox?-, gh?_, AKIA/ASIA, JWTs, Bearer), passwords in URLs, api_key= / token: / password= values |
[secret] |
Limits: patterns catch fixed shapes only. Names, postal addresses and free-form IDs are not caught, some phone shapes are missed, and an order number shaped like a phone number is masked. Text over 32,000 characters is not scanned: the field is withheld.
The redact hook runs after the built-in patterns, for what they cannot catch:
rudderstack.ai.configure(redact=lambda text: my_ner.mask_names(text))
rudderstack.ai.configure(redact=False) # opt out of masking entirely
A hook that raises or returns a non-string drops the field, so unmasked text is never sent. redact=None restores the default. The patterns are shared with the Node.js SDK (spec/fixtures/redaction-v0.1.json).
Tracing
rudderstack.ai.tracing (the tracing extra) is an OpenTelemetry span processor and exporter: each finished AI span becomes one ai_otel_span event with the complete OTLP span, sent through your RudderStack client. Use it to analyse spans in the warehouse or forward them to an OTLP endpoint through a RudderStack destination; skip it if product events are all you need. It also joins the two: spans started inside an interaction carry its interaction_id and identity, and the interaction's trace_id is the spans' trace ID.
from opentelemetry.instrumentation.openai_v2 import OpenAIInstrumentor
from rudderstack.ai.tracing import register_tracing
tracing = register_tracing() # client and capture_content from configure()
OpenAIInstrumentor().instrument(tracer_provider=tracing.tracer_provider)
- Existing OpenTelemetry setup (Langfuse, Traceloop, the distro): add
create_span_processor()to yourTracerProvidernext to your vendor's processor.register_tracing()adds it to an existing global SDK provider and leaves that provider running onshutdown(). - What is exported: AI spans only (
gen_ai.*, OpenInference or Vercel AI SDK attributes); HTTP and database spans are not, and get no identity attributes. Content attributes and events are removed unless content capture is on, then masked. The exporter truncates events overmax_event_bytes(default 32,000) and drops a span that still does not fit, with a warning. - Product events from spans:
register_tracing(product_events=True)records each LLM and tool span that ends inside an open interaction as a call on it, so OpenTelemetry alone producesai_response_received.
Options, the event shape, identity resolution, privacy lists and size limits: docs/tracing.md.
Serverless and workers
The RudderStack client sends events from a background queue. Before a short-lived process or handler exits, flush:
import rudderstack.ai
def handler(event: object, context: object) -> object: # AWS Lambda
try:
return run_agent(event)
finally:
rudderstack.ai.flush() # inside an event loop: await rudderstack.ai.aflush()
flush() waits for pending classifications (up to the classifier timeout plus a second), then flushes every RudderStack client the SDK has used: the configured one and those passed to contexts, interactions, wrappers and tracing. It returns False when a classification was still running. A non-chained ai_task flushes for you. For a long-lived worker or web app, flush once at shutdown (FastAPI lifespan, atexit.register(rudderstack.ai.flush)). With tracing, also call tracing.force_flush() (or use processor="simple"), since spans wait in the span processor's batch.
Events
| Event | When |
|---|---|
ai_user_prompt_created |
A user-triggered interaction starts (sent with its first model call) |
ai_response_received |
The interaction ends: totals, status, models, cost, usage_breakdown |
ai_user_action |
track_action(): feedback, copy, share, report, regenerate |
ai_interaction_classified |
The classifier labelled the interaction (opt-in) |
ai_llm_response_received |
One per model call (opt-in, emit_llm_calls=True; do not sum it with the totals) |
| your declared tool events | One per call of a tool named in event_rules |
ai_otel_span |
One per exported span (tracing module) |
Every event carries conversation_id, interaction_id, feature when set, and context.wrapperLibrary with the integration and specVersion. Property tables, sample payloads, cost rules and warehouse column types: docs/events.md. Load spec/rudderstack-catalog/ as a tracking plan before the first events arrive, so cost columns are created as decimals.
Configuration
| Option | Default | |
|---|---|---|
analytics |
none | Your RudderStack client (the rudderstack.analytics module or a Client). A client passed to an interaction, ai_context or integration wins. |
prices |
none | USD per 1M tokens ({"gpt-4.1-mini": {"input_per_mtok": 0.40, "output_per_mtok": 1.60}}), merged into the registry. Your rates; none ship with the package. |
capture_content |
False |
Send prompt, response and feedback text (masked). |
emit_llm_calls |
False |
Also send ai_llm_response_received per model call. |
tool_breakdown |
False |
Add tool_breakdown to ai_response_received. |
identity_from_cookies |
True |
Read the JavaScript SDK's cookies from a request. |
redact |
built-in masking | A (text) -> str hook run after masking, or False to turn masking off. |
event_rules |
none | {"tools": {name: rule}}: declared tool events. |
classifier |
none | Intent classification: {client, model, intents, ...} or {classify, ...}. |
azure_deployment_models |
none | {deployment: model}: price Azure OpenAI deployments as that model (Cloud model IDs). |
configure() changes only the options you pass; None clears analytics, event_rules and classifier. Prices match a model exactly or followed by a version suffix (-2025-04-14, -20251001, -0613, -v2, @20241022, -latest): gpt-4o prices gpt-4o-2024-08-06, never gpt-4o-mini. The full API is in docs/api.md; every public function has a docstring and type hints.
Troubleshooting
Turn on the package's logs with logging.basicConfig() and logging.getLogger("rudderstack.ai").setLevel(logging.DEBUG) (rudderstack.ai.tracing for spans).
| Symptom | Cause |
|---|---|
| No events, warning "no RudderStack client" | configure(analytics=...) was not called before the first event. |
| Events missing in a script, worker or Lambda | The process exited before the client's queue was sent: flush(). |
Every model call is its own ai_response_received |
Calls run outside ai_interaction: in a thread pool (use propagate), in a stream that outlives the interaction (use bind(); a warning names the interaction), or with the LangChain handler on a model instead of the run config. |
| No cost | No price registered for the model, or the name does not match (Configuration). A compatible endpoint that rejects stream_options sends no usage for streamed calls. |
| Cost column holds whole numbers | The warehouse created it as an integer: docs/events.md. |
No ai_interaction_classified |
The interaction was opened by an integration, the answer did not succeed, or an async client ran without an event loop (warning). |
| Prompt event arrives late | It is held until the first model call starts, so it carries that call's integration tag. |
register_tracing() raises RuntimeError |
The global provider is not an SDK TracerProvider, or already has a RudderStack processor. Use create_span_processor(). |
Compatibility
| Supported | Tested in CI | |
|---|---|---|
| Python | 3.12+ | 3.12, 3.13, 3.14 |
openai |
>=2.0.0,<4 |
2.0.0 and latest |
anthropic |
>=1.0.0,<2 |
1.0.0 and latest |
google-genai |
>=2.0.0,<3 |
2.0.0 and latest |
langchain-core |
>=1.2.0,<2 |
1.2.0 and latest, with LangGraph 1.x and Deep Agents 0.7 |
claude-agent-sdk |
>=0.2.82,<0.3 |
0.2.82 and latest |
openai-agents |
>=0.8.4,<0.24 |
0.8.4 and latest |
boto3 / botocore (Bedrock) |
>=1.37.24,<2 |
1.37.24 and latest |
opentelemetry-api / -sdk / -exporter-otlp-proto-common |
>=1.30.0,<2 |
1.30.0 and latest |
rudder-sdk-python |
2.x, or any object with its track() signature |
2.1.9 |
| Web frameworks (middleware) | Starlette/FastAPI, Django, Flask | Starlette 1.7, Django 6.1, Flask 3.1 |
Upgrading from 0.2.0
No API was removed. Behaviour changes:
- Masking is on. Captured
prompt_text,response_textandfeedback_text(and captured span content inai_otel_span) arrive masked.configure(redact=False)restores the 0.2.0 output. - Price matching is stricter. A registered name prices only that model and its dated or versioned variants, no longer any model it is a prefix of. Register each model family you use (
gpt-4o-miniseparately fromgpt-4o). - Cost rounding is half-up at 8 decimals, like the Node.js SDK (0.2.0 rounded half-to-even): about 1% of values differ by 1e-8.
- LangChain providers use OpenTelemetry names:
amazon_bedrock→aws.bedrock,google_vertexai→gcp.vertex_ai,google_genai→gcp.gemini,azure→azure.ai.openai. Update prices registered withprovider=and dashboards grouped by provider. - Tracing identity (
user.id,rudderstack.anonymous_id) is added only to AI spans, and never to spans insideai_task. ai_taskchained inside another interaction no longer flushes by default (flush=Truerestores it).- A missing RudderStack client logs a warning (once) instead of a debug message;
end(status=...)with an invalid status derives the status and warns instead of dropping the event. - Deprecated (still working, removed in 1.0):
Interaction.adopt_analytics,note_integration,flush_prompt,record_span_call,note_outcome,mark_first_token,track_stream,untrack_stream;start_interaction(integration=...); and fromrudderstack.ai.tracing:AnalyticsClient,__version__, theCONTENT_*constants,is_content_attribute,is_content_event.
New since 0.2.0: identity from cookies and rudderstack.ai.middleware, intent classification, declared tool events, tool breakdown, masking, flush() / aflush(), propagate(), estimate_cost(), register_prices(), error helpers. See the changelog.
Known limitations
- Not tracked: OpenAI
with_streaming_response, a streamedwith_raw_responsecall and a non-streamedresponses.create(background=True)(only queued; polling is not intercepted). Userecord_callfor those. - A structured
responses.parse()call cut off at the token limit raises while parsing, before the SDK returns the response: it is recorded as a failed call without its tokens. - A streamed LangChain
ChatOpenAIcall has noresponse_id(langchain-openai replaces it with a run ID). - With claude-agent-sdk versions whose
ResultMessagelacksterminal_reason, an aborted run is reported as a success. - The
.stream()helpers are observed through a private attribute of the SDK; if a future SDK renames it, calls still work but go untracked (CI's latest-versions job catches it). - Platform differences from the Node.js SDK: no Vercel AI SDK (JavaScript only);
flush()is sync with an asyncaflush(); the Claude Agent SDK's Python parser dropsmcp_tool_useblocks before the SDK sees them.
Contributing
See CONTRIBUTING.md and RELEASING.md. Report security issues as described in SECURITY.md.
License
Metadata
Release files for rudderstack-ai 0.4.0
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.4.0.tar.gz | 148.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| rudderstack_ai-0.4.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 298.6 kB
Release files / rudderstack_ai-0.4.0.tar.gz
| Download URL | rudderstack_ai-0.4.0.tar.gz |
|---|---|
| Size | 148.5 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b8ddefe582403f32924eba39c0db6b06f45b11f56767594a41ae0ce0581f741b
|
|
BLAKE2b-256 checksum How to use checksums |
8decf1d2aaca020512e51ec95d8bae4ad857c032761e80f74397f340416581f0
|
| 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 14, 2026.
Transparency logRelease files / rudderstack_ai-0.4.0-py3-none-any.whl
| Download URL | rudderstack_ai-0.4.0-py3-none-any.whl |
|---|---|
| Size | 150.1 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b26fe279bca4d6cc80b9498f181194edaac3b5ba98ed31e51fccc593c1507f6c
|
|
BLAKE2b-256 checksum How to use checksums |
82cfc1ee631a6effdb758f0fe004a06328c8c7419a49fa916992831048b48c61
|
| 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 14, 2026.
Transparency log