Trace CrewAI crews and flows with OpenTelemetry
Send CrewAI crews and flows to Maple with OpenInference, one Agent Session per conversation.
CrewAI needs two OpenInference instrumentors: openinference- for crews, agents and tools, and one for your model provider, which records prompts and tokens. You also wrap every kickoff() in a conversation id so a chat becomes one session.
Quick setup with a coding agent
Copy this prompt into a coding agent that can run shell commands, such as Claude Code, Codex or Cursor. It installs the maple-agent-tracing-crewai skill and follows it.
Set up Maple agent tracing for CrewAI in this project.
Install the skill, then follow it:
npx skills add MapleTechLabs/maple/skills --skill maple-agent-tracing-crewai -y
My Maple ingest key is maple_pk_... and my organization is in the US region.
Use the Public key (maple_pk_…) from Settings → Ingestion. The agent may write the key into your source code, and a public key can only send telemetry, so it is safe to commit. If your organization is in the EU region, change US to EU in the prompt.
Install the instrumentors
pip install "crewai>=1.15" "openinference-instrumentation-crewai>=1.1.18" \
"openinference-instrumentation-openai>=0.1.61" \
"opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45"uv add "crewai>=1.15" "openinference-instrumentation-crewai>=1.1.18" \
"openinference-instrumentation-openai>=0.1.61" \
"opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45"Pick the model instrumentor by the model string you pass to LLM(...):
| Model string | Instrumentor |
|---|---|
openai/…, openrouter/…, deepseek/…, ollama/…, custom_openai=True, or a bare name like gpt-4.1-mini | openinference- |
anthropic/… or a bare claude-… | openinference- |
gemini/… or a bare gemini-… | openinference- |
bedrock/… | openinference- |
Anything else (needs crewai[litellm]) | openinference- |
Install only the ones your crews use. The LiteLLM instrumentor records nothing for the first four rows.
Point the exporter at Maple
export OTEL_SERVICE_NAME=support-crew
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production
export OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
export CREWAI_DISABLE_TELEMETRY=true
export CREWAI_TRACING_ENABLED=false
EU organizations use https://ingest.eu.maple.dev. If you pass endpoint= to OTLPSpanExporter in code instead, it has to end in /v1/traces.
The last two turn off CrewAI’s own telemetry and a first-run prompt that blocks the process at exit. Don’t use OTEL_SDK_DISABLED=true instead, because it disables your Maple traces too.
Initialize tracing
Add a tracing.py and import it at the top of your entry point, before the first kickoff():
# tracing.py
from openinference.instrumentation import TraceConfig
from openinference.instrumentation.crewai import CrewAIInstrumentor
from openinference.instrumentation.openai import OpenAIInstrumentor
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import SpanProcessor, TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
class CrewAIAgentNames(SpanProcessor):
def on_start(self, span, parent_context=None):
parent = trace.get_current_span(parent_context)
attrs = getattr(parent, "attributes", None) or {}
role = attrs.get("graph.node.id")
if role and "gen_ai.agent.name" not in attrs and parent.is_recording():
parent.set_attribute("gen_ai.agent.name", role)
provider = TracerProvider()
provider.add_span_processor(CrewAIAgentNames())
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
config = TraceConfig(enable_genai_semconv=True)
CrewAIInstrumentor().instrument(tracer_provider=provider, config=config, skip_dep_check=True)
OpenAIInstrumentor().instrument(tracer_provider=provider, config=config, skip_dep_check=True)
Pass config to every instrumentor. Keep skip_dep_check=True, or an instrumentor can silently skip itself.
If the app already has a TracerProvider (from opentelemetry-instrument, Logfire or Sentry), add CrewAIAgentNames() and the exporter to it and pass it to instrument().
Group a conversation into one session
CrewAI has no conversation id, so wrap every kickoff() in OpenInference’s using_session with the id your app stores the chat under:
import tracing # noqa: F401 (first import)
from crewai import LLM, Agent, Crew, Task
from openinference.instrumentation import using_session
llm = LLM(model="openai/gpt-4o-mini", temperature=0)
def build_crew(text: str, history: str, stream: bool = False) -> Crew:
assistant = Agent(
role="assistant",
goal="Answer the user's questions",
backstory="You are a concise, helpful assistant.",
llm=llm,
tools=[get_weather, calculate],
)
task = Task(
description=f"{text}\n\nConversation so far:\n{history}",
expected_output="A short, direct reply to the user.",
agent=assistant,
name="reply",
)
return Crew(name="support", agents=[assistant], tasks=[task], stream=stream)
def handle_message(conversation_id: str, text: str, history: str) -> str:
with using_session(conversation_id):
return build_crew(text, history).kickoff().raw
Put the user’s message first in the task description, because Maple labels each turn with its first line. Give the crew a name=. crew_id and crew_key don’t work as conversation ids.
For conversational flows, wrap flow. in the same using_session and set name = "support_flow" on the flow class.
Use kickoff() or await crew.kickoff_async(), never akickoff(), which isn’t traced as one run.
Streaming crews
Crew(stream=True) adds an empty turn to every message. Wrap the streamed turn in one span of your own:
from opentelemetry import trace
tracer = trace.get_tracer("chat")
def stream_message(conversation_id: str, text: str, history: str, send) -> None:
with using_session(conversation_id), tracer.start_as_current_span(
"invoke_agent support",
attributes={
"gen_ai.operation.name": "invoke_agent",
"gen_ai.conversation.id": conversation_id,
},
):
for chunk in build_crew(text, history, stream=True).kickoff():
send(chunk.content)
LLM(stream=True) without Crew(stream=True) doesn’t need this.
Flush in short-lived processes
Servers and crewai run need nothing. In serverless handlers and notebooks, import provider from tracing and call provider.force_flush() in a finally after each run.
Verify
Send two or three messages with the same conversation id, one of them using a tool, then open Agent Sessions. Within a minute you should see one session labeled CrewAI, with one turn per kickoff() starting at support.kickoff, ChatCompletion model calls with tokens, tool calls like get_weather.run, and one lane per agent role. Cost shows as unpriced unless your models go through LiteLLM.
Troubleshooting
- Agent and tool spans, but no model calls or tokens. The instrumentor for your model’s SDK is missing. Match it to the model string with the table above.
- No spans at all. Import
tracingbefore the first kickoff, keepskip_dep_check=True, make sureOTEL_SDK_DISABLEDisn’t set, and check the logs for exporter errors. - One session per message. The kickoff isn’t inside
using_session(...), or the id changes per request. - Every model and tool call is its own trace. Replace
akickoff()withkickoff()orkickoff_async(). - The process hangs at exit asking about traces. Set
CREWAI_TRACING_ENABLED=false.
Next steps
- Agent Sessions overview: what Maple builds from these spans.
- LiteLLM and OpenRouter: if your models go through either gateway.