How do I trace an agent built with CrewAI and deployed to a FastAPI service?
Rius traces a CrewAI agent in a FastAPI service when you call init once at process start and wrap the code that runs the crew with observe. Model calls arrive as generations with tokens and cost, and your wrapper becomes the root agent span. In a long-running Python server Rius flushes spans for you on a clean shutdown, and the name you pass to init is the name the agent is grouped under.
How it works
CrewAI 1.x drives the OpenAI client natively, so the openai integration captures its model calls. When LiteLLM is installed, CrewAI uses it as a fallback, and you rely on the LiteLLM integration instead. With a default init(), every installed integration is enabled automatically, so there is nothing to switch on per route. The full list is on the Integrations page.
Crews and tasks produce no spans of their own, because that structure runs inside the framework. You add the shape yourself:
- Wrap the function that calls
crew.kickoff()withobserveand the AGENT kind. It becomes the root span of each run. - Wrap each tool body with
observeand the TOOL kind. The tool span parents under the run through ambient context. - Model calls show as generations named
ChatCompletion, with model, tokens, cost and time to first token.
Spans are queued in memory and exported in batches from a background thread, so tracing does not sit on your request path. The CrewAI integration page covers the blind spots in detail.
Set it up
- Install the SDK with the OpenAI extra next to CrewAI.
pip install "glassflow-rius[openai]" crewai- Call
rius.initonce, when the service process starts, at the top of the module that defines your crew entrypoint, before the first request runs a crew. Do not call it inside a request handler. Put the crew logic in a function likehandle_ticketbelow and call it from a plaindefroute, which FastAPI runs in a thread pool so the blockingkickoff()does not stall the event loop. The decorator order on tools matters:@tooloutermost,observeon the function body.
from contextlib import asynccontextmanager
import rius
from crewai import Agent, Crew, Task
from fastapi import FastAPI
from crewai.tools import tool
from rius import SpanKind
client = rius.init(api_key="ri_...", service_name="my-agent")
@tool("account_status")
@rius.observe(kind=SpanKind.TOOL)
def account_status(email: str) -> str:
"""Fetch a customer's account standing by email."""
return lookup(email)
support_agent = Agent(
role="Support specialist",
goal="Answer support tickets accurately and concisely.",
backstory="You resolve product and account questions.",
tools=[account_status],
llm="gpt-5-mini",
)
@rius.observe(name="handle-ticket", kind=SpanKind.AGENT)
def handle_ticket(ticket: str) -> str:
task = Task(
description=f"Resolve this support ticket: {ticket}",
expected_output="A short, direct answer.",
agent=support_agent,
)
return str(Crew(agents=[support_agent], tasks=[task]).kickoff())
@asynccontextmanager
async def lifespan(app: FastAPI):
yield
client.shutdown() # drain spans, send the final stopped heartbeat
app = FastAPI(lifespan=lifespan)
@app.post("/tickets")
def create_ticket(ticket: str) -> dict:
return {"answer": handle_ticket(ticket)}-
Name the agent. The
service_nameyou pass to init, orRIUS_SERVICE_NAME, is the agent’s name by default.RIUS_AGENT_NAMEoverrides the name the agent is grouped under. Thenameon the AGENT wrapper, herehandle-ticket, names the root span of each run. -
Turn off CrewAI’s own tracing prompt. CrewAI asks about its tracing product on first run, so set
CREWAI_TRACING_ENABLED=falsein the container or any place with no terminal attached. -
Decide how spans are flushed, based on how the service runs:
- Long-running server, including gunicorn workers: you do nothing. Batches export continuously, and the exit hook drains the rest on a clean shutdown. Each forked worker re-creates its own exporter thread, so there is nothing to do at fork time.
- Hard kills: call
flush()in your own shutdown code, because an unclean worker shutdown can lose the last batch. - Serverless runtime: call
client.flush()at the end of every invocation, and setheartbeat=False(orRIUS_HEARTBEAT=false). With the heartbeat off, the agent is marked no heartbeat and lists no instances.
Never call flush() inside a request handler. It blocks until the export completes. Flush at process or invocation boundaries only. More on delivery and retries is on the Reliability page.
- Group requests into sessions. Scope each kickoff with
rius.session(), reusing your own conversation or job id.
What you get
- One trace per request, with your named AGENT root span, a generation for each model call and a span for each of your tools.
- Tokens and cost on every generation. Costs are list-price estimates in US dollars.
- An agent in the Agents view under the name you chose. With the heartbeat on, a gunicorn app shows one instance per worker.
- A clean stop signal when you call
shutdown(). An unhandledSIGTERMor aSIGKILLsends nostoppedping, so the instance reportsgone, which is the crash signal. - Streamed calls carry token counts only when usage is enabled on the underlying OpenAI client.
To verify, send one request that calls a tool and open the trace in the console. You should see the root AGENT span, the LLM generations and your named TOOL spans.
When something else fits
- If you need to know which CrewAI task a generation belongs to, wrap per-task entrypoints with
observe, since crews and tasks produce no spans on their own. - If your crew runs where LiteLLM is installed, follow the LiteLLM integration, because CrewAI then routes model calls through it.
- If the service is written in TypeScript, there is no CrewAI integration in that language, so trace those calls with manual generations.