Skip to Content

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() with observe and the AGENT kind. It becomes the root span of each run.
  • Wrap each tool body with observe and 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

  1. Install the SDK with the OpenAI extra next to CrewAI.
pip install "glassflow-rius[openai]" crewai
  1. Call rius.init once, 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 like handle_ticket below and call it from a plain def route, which FastAPI runs in a thread pool so the blocking kickoff() does not stall the event loop. The decorator order on tools matters: @tool outermost, observe on 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)}
  1. Name the agent. The service_name you pass to init, or RIUS_SERVICE_NAME, is the agent’s name by default. RIUS_AGENT_NAME overrides the name the agent is grouped under. The name on the AGENT wrapper, here handle-ticket, names the root span of each run.

  2. Turn off CrewAI’s own tracing prompt. CrewAI asks about its tracing product on first run, so set CREWAI_TRACING_ENABLED=false in the container or any place with no terminal attached.

  3. 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 set heartbeat=False (or RIUS_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.

  1. 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 unhandled SIGTERM or a SIGKILL sends no stopped ping, so the instance reports gone, 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.

Still stuck? Get help

Last updated on