Tracing CrewAI agent workflows requires visibility into each agent’s reasoning, tool calls, and handoffs. This tutorial builds a lightweight tracer that captures every step, then upgrades it to OpenTelemetry spans so you can debug multi-agent failures in production.
Prerequisites
- Python 3.10 or newer
pip install crewai openai opentelemetry-sdk opentelemetry-exporter-console- An OpenAI API key, or an OpenAI-compatible endpoint URL and key
Set your key before running any code:
export OPENAI_API_KEY=sk-...
# or for a gateway:
export N4N_KEY=your-key
If you point CrewAI at an OpenAI-compatible gateway like n4n.ai, you get automatic fallback when a provider is rate-limited and per-token usage metering while tracing CrewAI agent workflows.
Define a minimal crew
We’ll build a two-agent crew: a researcher that gathers facts and a writer that drafts a paragraph. Use crewai 0.30+ API.
import os
from crewai import Crew, Agent, Task, LLM
llm = LLM(
model="openai/gpt-4o-mini",
temperature=0.2,
# For n4n.ai, set base_url="https://api.n4n.ai/v1" and api_key=os.environ["N4N_KEY"]
)
researcher = Agent(
role="Researcher",
goal="Find three key facts about OTel tracing",
backstory="You are a diligent analyst.",
llm=llm,
verbose=False,
)
writer = Agent(
role="Writer",
goal="Turn facts into a concise paragraph",
backstory="You are a clear technical writer.",
llm=llm,
verbose=False,
)
research_task = Task(
description="List three facts about OpenTelemetry tracing.",
expected_output="Bullet list of three facts.",
agent=researcher,
)
write_task = Task(
description="Write a paragraph using the facts.",
expected_output="One paragraph.",
agent=writer,
context=[research_task],
)
crew = Crew(
agents=[researcher, writer],
tasks=[research_task, write_task],
verbose=False,
)
Add a step callback for basic tracing
CrewAI invokes step_callback after each agent step. The callback receives a StepOutput object with text, agent, and tools. We’ll log it as JSON to get a timeline.
import json
import datetime
def step_logger(step):
record = {
"ts": datetime.datetime.utcnow().isoformat(),
"agent": step.agent.role if step.agent else None,
"tools": [t.name for t in (step.tools or [])],
"output": step.text[:200],
}
print("STEP:", json.dumps(record))
crew.step_callback = step_logger
Run the crew:
result = crew.kickoff()
print("FINAL:", result)
Expected output (truncated):
STEP: {"ts": "2024-05-12T10:22:01.123", "agent": "Researcher", "tools": [], "output": "- OTel uses spans...\n- Spans have context propagation...\n- Exporters send to backends"}
STEP: {"ts": "2024-05-12T10:22:05.456", "agent": "Writer", "tools": [], "output": "OpenTelemetry tracing models work as spans with context propagation..."}
FINAL: OpenTelemetry tracing models work as spans with context propagation...
This already gives you a timeline. When tracing CrewAI agent workflows across many agents, though, flat logs make it hard to see parent-child relationships.
Build structured spans with OpenTelemetry
Replace the logger with an OTel tracer. Initialize a TracerProvider and a console exporter for local inspection.
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("crewai.tracer")
def otel_step_callback(step):
agent_role = step.agent.role if step.agent else "unknown"
span = tracer.start_span(f"crew.step.{agent_role}")
span.set_attribute("agent.role", agent_role)
span.set_attribute("output.snippet", step.text[:100])
span.end()
Attach it and wrap the run in a parent span:
crew.step_callback = otel_step_callback
with tracer.start_as_current_span("crew.run") as run_span:
run_span.set_attribute("crew.agents", "Researcher,Writer")
crew.kickoff()
Console shows span objects with IDs and timestamps. Each step is now a discrete span under crew.run.
Trace tool calls inside agents
Agents using tools need deeper tracing. CrewAI passes tool calls through the LLM interface; we can subclass LLM to emit spans around call.
from crewai import LLM
class TracedLLM(LLM):
def call(self, messages, *args, **kwargs):
with tracer.start_as_current_span("llm.call") as span:
span.set_attribute("messages.count", len(messages))
response = super().call(messages, *args, **kwargs)
span.set_attribute("response.length", len(response))
return response
traced_llm = TracedLLM(model="openai/gpt-4o-mini")
researcher.llm = traced_llm
writer.llm = traced_llm
Now every LLM completion appears as a child span under the step that triggered it. If you add crewai[tools] and give agents tools=[...], the same llm.call span will cover the model’s tool-selection turn.
Run the full traced workflow
Final assembly:
from crewai import Crew, Agent, Task, LLM
from opentelemetry import trace
# ... provider setup from above ...
llm = TracedLLM(model="openai/gpt-4o-mini")
# agents, tasks defined as before, using llm
crew = Crew(
agents=[researcher, writer],
tasks=[research_task, write_task],
step_callback=otel_step_callback,
)
with tracer.start_as_current_span("crew.run"):
outcome = crew.kickoff()
Expected span tree (simplified):
crew.run
├── crew.step.Researcher
│ └── llm.call
├── crew.step.Writer
│ └── llm.call
When tracing CrewAI agent workflows in production, export these spans to Jaeger or Tempo instead of the console by swapping ConsoleSpanExporter for an OTLPSpanExporter.
Propagate context across tasks
The write_task depends on research_task via context. To make that linkage explicit in spans, add a task ID attribute in the step callback by inspecting step.task if available, or by mapping agent roles to task IDs in your own dict.
task_map = {
"Researcher": "task.research",
"Writer": "task.write",
}
def otel_step_callback(step):
agent_role = step.agent.role if step.agent else "unknown"
span = tracer.start_span(f"crew.step.{agent_role}")
span.set_attribute("task.id", task_map.get(agent_role, "unknown"))
span.end()
Now your backend can reconstruct the DAG of agent work.
Handle provider degradation
If you run this against a single OpenAI key, a rate limit stalls the whole crew. Using an OpenAI-compatible endpoint that honors client routing directives fixes that. For example, set base_url on the LLM:
llm = TracedLLM(
model="openai/gpt-4o-mini",
base_url="https://api.n4n.ai/v1",
api_key=os.environ["N4N_KEY"],
)
The gateway forwards provider cache-control hints and fails over to another provider without code changes. Your spans stay identical; only the backend shifts.
Why this matters
Flat verbose=True output is unparseable at scale. Structured spans let you query “which agent step took longest” or “which LLM call returned empty” in a real observability backend. Tracing CrewAI agent workflows end to end is mostly about instrumenting the seams: step boundaries, LLM calls, and task dependencies. Do that once and multi-agent debugging stops being guesswork.
Key takeaways
- Use
step_callbackto capture agent outputs with timestamps. - Promote callbacks to OpenTelemetry spans for hierarchy and export.
- Subclass
LLM.callto trace model invocations per step. - Resilient inference routing keeps traces continuous during provider errors.