How do I trace an agent built with CrewAI and deployed to a Celery worker?
Rius traces a CrewAI crew when you install the OpenAI integration, call rius.init in each worker process after it forks, and wrap your entrypoint and tool bodies with observe. Name the agent with service_name and the name on your root observe wrapper. Celery’s worker processes skip Python’s exit hook, so you flush and shut the client down from the worker_process_shutdown signal.
How it works
- Model calls. CrewAI 1.x drives the OpenAI client natively, so the
openaiintegration captures its model calls as generations with model, tokens, cost and time to first token. If LiteLLM is installed, CrewAI uses it instead, and you use thelitellmintegration. See CrewAI and the Integrations matrix. - Run structure. Crews and tasks produce no spans of their own, so your
observewrappers give the run its shape. A root wrapper of kind AGENT aroundcrew.kickoff()becomes the root span. A wrapper of kind TOOL on each tool body becomes a tool span under it. - Export. Spans are queued in memory and sent in batches from a background thread, so tracing never sits on your agent’s hot path.
- Forked workers. Celery’s prefork pool forks its worker processes and ends them through
os._exit, which skips Python’s exit hook. So start Rius in each child fromworker_process_initand shut it down fromworker_process_shutdown. Each child gets its own heartbeat instance ID, so the Agents page shows one instance per worker process.
Set it up
-
Install the SDK with the OpenAI extra, next to CrewAI. The extra installs the instrumentation, not the OpenAI library, which CrewAI already brings. Installation is covered in full on the CrewAI page.
pip install "glassflow-rius[openai]" crewai -
Call
rius.initin each worker process, never at module scope, because the parent process imports your tasks module too. Decide the agent’s names there.service_nameis the name the agent is grouped under, becauseRIUS_AGENT_NAMEdefaults to the service name. Name the root wrapper too, since the root span takes the name you pass toobserve. Decorator order on tools matters:@tooloutermost,observeon the function body.import rius from celery import Celery from celery.signals import worker_process_init, worker_process_shutdown from crewai import Agent, Crew, Task from crewai.tools import tool from rius import SpanKind app = Celery("agents", broker="redis://localhost:6379/0") client = None @worker_process_init.connect def start_rius(**kwargs): global client client = rius.init(api_key="ri_...", service_name="my-agent") @worker_process_shutdown.connect def stop_rius(**kwargs): if client is not None: client.flush(timeout_millis=10_000) # force an export client.shutdown() # drain, stop the thread, send the stopped heartbeat @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", ) @app.task @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 Crew(agents=[support_agent], tasks=[task]).kickoff().rawThe threads, gevent and eventlet pools do not send
worker_process_init, so with those pools call init once when the worker starts. -
Keep CrewAI’s own tracing prompt out of a process with no terminal by setting
CREWAI_TRACING_ENABLED=falsein the worker’s environment. CrewAI otherwise asks about its tracing product interactively on first run. -
Group each job’s traces by reusing your own job or conversation id with
rius.session()around the kickoff. See Integrations for the rest of the setup options. -
Flush only at the process boundary, as the shutdown handler does. Do not call
flush()inside a task, because it blocks until the export completes. A worker process killed withSIGKILL, by a hard time limit or the out-of-memory killer, loses the spans still queued in the batch and sends nostoppedheartbeat. Reliability covers batching, retries and shutdown. -
Run one ticket that calls a tool, then open the trace in the console.
What you get
- A root span of kind AGENT named
handle-ticket, or whatever name you gave the wrapper. - An LLM generation for each model call, with model, tokens, cost and time to first token. Cost is a list-price estimate in US dollars that Rius computes, so you never send it.
- A TOOL span for each tool, named after the tool, parented under the run.
- Spans from one job grouped in a session, and an agent on the Agents page under your
service_name, with one instance per process while the heartbeat is on. - Generation spans carry the generic name
ChatCompletion. Crews and tasks have no spans, so which task a generation belongs to is not visible unless you wrap per-task entrypoints withobserve.
When something else fits
- If LiteLLM is installed in the worker’s environment, use the
litellmintegration instead ofopenai. - If you need to see which task a model call belongs to, add an
observewrapper around each task’s entrypoint. - If workers run with the heartbeat off or without credentials, the agent is marked no heartbeat and lists no instances, though traces still arrive.
Related
- Integrations
- How do I trace an agent built with LangChain and deployed to a Kubernetes deployment?
- How do I trace an agent built with LlamaIndex and deployed to a Kubernetes deployment?
- How do I trace an agent built with the OpenAI Agents SDK and deployed to a Kubernetes deployment?
Still stuck? Get help