Trace Agno agents with Maple: installs the OpenInference Agno instrumentor with an OTLP exporter and GenAI attributes, and sets session_id per conversation so each chat is one Maple Agent Session with transcript, tool calls, team members, tokens and cost. Triggers on 'trace my agno agent', 'add Maple to agno', 'agent sessions for agno', 'OpenTelemetry for agno'.
73
92%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
Low
Low-risk findings worth noting
Goal: every conversation with the Agno app shows up in Maple Agent Sessions as exactly one session, one turn per run(), with transcript, model calls, tool calls (failures marked), team-member lanes, tokens and cost where the provider returns it.
Mechanism: openinference-instrumentation-agno + OTel SDK + OTLP/HTTP exporter to Maple. Agno's setup_tracing(db=...) and AgentOS(tracing=True) only write to the AgentOS database; they never export OTLP.
pyproject.toml, requirements*.txt, uv.lock, poetry.lock) and the installed agno version. Target agno>=3.0 (verified 3.0.11). On Agno 2.x, openinference-instrumentation-agno needs agno>=2.5.0; the rest of this skill applies unchanged.TracerProvider(, set_tracer_provider, AgnoInstrumentor, setup_tracing(, tracing=True, phoenix.otel.register, openlit.init, logfire.configure, langfuse, OpenAIInstrumentor, LiteLLMInstrumentor.
TracerProvider of the app's own: reuse it. Add a BatchSpanProcessor(OTLPSpanExporter(...)) for Maple to it. Do not create a second provider.AgnoInstrumentor().instrument(...): edit that call (add config=); never call instrument() a second time (the second call is a silent no-op).AgentOS(tracing=True) or setup_tracing(db=...): see Step 2c.register(auto_instrument=True)): remove it or scope it away from Agno, or every model call is recorded twice.Agent, Team or Workflow is run: .run(, .arun(, .print_response(, .aprint_response(, .continue_run(, .acontinue_run(. Note where the conversation/thread id lives in the request.https://ingest.maple.dev. EU: https://ingest.eu.maple.dev.Authorization=Bearer <key>. Protocol http/protobuf.MAPLE_TEST (ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion.maple_sk_ keys never go in browser code. Agno runs server-side; a maple_pk_ ingest key is write-only..env, settings module, secret manager). If there is none, inline is acceptable because ingest keys are write-only..env (load_dotenv()): call it at the top of tracing.py, before the provider is built. Otherwise the exporter silently targets localhost:4318 with no key.tracing.py, after any load_dotenv(), when OTEL_EXPORTER_OTLP_HEADERS is unset, log one warning (logging.getLogger(__name__).warning("OTEL_EXPORTER_OTLP_HEADERS (Maple ingest key) is not set; Maple telemetry export is disabled")) and skip the provider and exporter setup. Never raise or exit over the key, and never send a header without one (opaque 401).Add to the project's dependency file with its package manager (uv/poetry/pip):
agno>=3.0
openinference-instrumentation-agno>=1.0.10
opentelemetry-sdk
opentelemetry-exporter-otlp-proto-http>=1.0.8 is required for human-in-the-loop continue_run() spans; 1.0.10 adds llm.cost.total. If the app uses SqliteDb/AsyncSqliteDb and imports fail with "requires ... 'greenlet'", add greenlet (and aiosqlite or agno[sqlite]).
OTEL_SERVICE_NAME=<service name, e.g. support-agent>
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=<env>
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev # EU: https://ingest.eu.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
AGNO_TELEMETRY=falseThe exporter appends /v1/traces to OTEL_EXPORTER_OTLP_ENDPOINT. If you use OTEL_EXPORTER_OTLP_TRACES_ENDPOINT instead, give the full .../v1/traces URL.
Create tracing.py (or add to the app's existing observability module):
from openinference.instrumentation import TraceConfig
from openinference.instrumentation.agno import AgnoInstrumentor
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
provider = TracerProvider() # resource from OTEL_SERVICE_NAME / OTEL_RESOURCE_ATTRIBUTES
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
AgnoInstrumentor().instrument(
tracer_provider=provider,
config=TraceConfig(enable_genai_semconv=True),
)enable_genai_semconv=True is REQUIRED. Env equivalent: OPENINFERENCE_ENABLE_GENAI_SEMCONV=true (use it when you can't edit the instrument() call, e.g. AgentOS owns it).
Import tracing at the top of the entry point (main.py, app.py, the ASGI module), before building agents, teams or AgentOS.
AgentOS: AgentOS(tracing=True) and setup_tracing(db=...) skip their setup when a real TracerProvider is already registered, so AgentOS's traces view stops getting spans once tracing.py runs first. If the user wants to keep that view, add Agno's DB exporter to the same provider (use the db the AgentOS uses):
from agno.tracing.exporter import DatabaseSpanExporter
provider.add_span_processor(BatchSpanProcessor(DatabaseSpanExporter(db=db)))If setup_tracing/tracing=True must run first instead, don't create a provider: call trace.get_tracer_provider().add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) after it and set OPENINFERENCE_ENABLE_GENAI_SEMCONV=true in the environment before the process starts.
session_id=<conversation id> on EVERY run, arun, print_response, aprint_response, continue_run, acontinue_run, and on team.run/arun and workflow.run/arun. Use the id the app already stores for the chat/thread; pass user_id= too if available.session_id=, Agno mints a uuid4() on the first run and stores it on the Agent/Team instance; every later run of that instance reuses it. A module-level shared agent then merges all users into one session. Fix it; don't rely on the default.using_session() or a custom span processor for the session; Agno's run span already carries it.db, session_id also selects the stored history: keep them the same id.gen_ai.input.messages / gen_ai.output.messages on span attributes. Nothing else to enable.OPENINFERENCE_HIDE_INPUT_MESSAGES=true, OPENINFERENCE_HIDE_OUTPUT_MESSAGES=true, OPENINFERENCE_HIDE_INPUTS=true, OPENINFERENCE_HIDE_OUTPUTS=true. Tell them the transcript will be empty but tokens/tools/failures remain, and that tool ARGUMENTS (tool.parameters, gen_ai.tool.call.arguments) are NOT masked by any of these variables; dropping them needs an OTel Collector redaction/transform processor.str(result) (the same string Agno sends to the model). A tool returning a dict/list produces a Python repr ({'city': 'Berlin'}), which is not JSON, so Maple shows plain text. Recommend returning json.dumps(...) from structured tools; tell the user rather than silently changing tool return types.Tool spans are named after the function and carry gen_ai.tool.name, arguments and result. A tool that RAISES gets status ERROR with the exception message; Maple counts it as failed. A tool that RETURNS an error string counts as success. If the user's tools swallow errors into strings, point this out; don't change behavior unasked.
Give every Agent and Team a name= (distinct per member). Unnamed ones produce Agent.run/Team.run spans with no gen_ai.agent.name, and Maple can't draw a lane for them.
Teams: one trace per team run. Member runs (<member>.arun) are children of the team run span, siblings of the leader's delegate_task_to_member tool spans (not nested inside them). Maple draws a lane per named member. Nothing to add.
Team context leak (instrumentor 1.0.10): after team.run()/team.arun() returns, the team span stays attached to the thread/task, so any LATER agent run in the same thread/task nests into the finished team trace. Per-request tasks/copied contexts (FastAPI, Starlette) are unaffected. In scripts, loops, workers, CLIs and tests, isolate each team run:
result = await asyncio.create_task(team.arun(prompt, session_id=conversation_id)) # async
result = contextvars.copy_context().run(team.run, prompt, session_id=conversation_id) # syncPlain Agent runs (sync, async, streamed) do not leak.
Human-in-the-loop (@tool(requires_confirmation=True) → requirement.confirm()/reject() → agent.continue_run(run_id=..., requirements=..., session_id=...), agent needs a db):
response = agent.run(message, session_id=conversation_id)
if response.is_paused:
for requirement in response.active_requirements:
requirement.confirm() # or requirement.reject(note="...")
response = agent.continue_run(
run_id=response.run_id, requirements=response.requirements, session_id=conversation_id
)The resumed run is its own trace with a <agent>.continue_run span carrying session.id, so it joins the session as a second turn. Needs instrumentor >=1.0.8 and session_id= on continue_run.
Long-running server (FastAPI, AgentOS): nothing to add; TracerProvider flushes at normal exit.
Script / CLI / worker: wrap the entry point:
from tracing import provider
try:
main()
finally:
provider.force_flush()
provider.shutdown()Took the setup_tracing path in Step 2c (no provider of your own): flush with trace.get_tracer_provider().force_flush().
Serverless handler (Lambda, Cloud Run jobs, etc.): provider.force_flush() before returning from EVERY invocation; never shutdown().
Notebook: provider.force_flush() after the cell that runs the agent.
Run one real conversation: 2-3 turns with the same session_id including one tool call, plus a second conversation with a different id. Flush. Wait ~1 minute. In Maple Agent Sessions, filtered by the service name (or via the Maple MCP list_agent_sessions + get_agent_session), check:
trace:<id> (that means a run span had no session.id).run()/arun(); turn labels are the user messages.<ModelClass>.invoke|ainvoke|invoke_stream|ainvoke_stream) show the model id and non-zero input/output tokens, including streamed turns.Bearer.No scriptable entry point (server, UI, REPL only): write a small driver for this run: one session_id, 2+ turns, one tool call, flush before exit.
Without Maple access (MAPLE_TEST, no MCP): the run must exit with no export errors on stderr (Failed to export, 401 lines) AND a local exporter (provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())) in a scratch run) must show the spans below. Silence alone proves nothing (no spans is silent too). With the Maple MCP: list_agent_sessions with search=<session_id> returns one row.
401 ingest_unauthorized / "Invalid ingest key" with a key you trust: keys are region-bound, so it likely belongs to the other region; try the other endpoint.
Raw span check: run spans <agent_name>.run have session.id + gen_ai.operation.name=invoke_agent + gen_ai.agent.name; model spans have gen_ai.operation.name=chat, gen_ai.input.messages, gen_ai.usage.input_tokens; tool spans have gen_ai.operation.name=execute_tool.
setup_tracing()/AgentOS(tracing=True) ran first, set_tracer_provider in tracing.py logs "Overriding of current TracerProvider is not allowed" and spans go only to the AgentOS database. A second instrument() call logs "Attempting to instrument while already instrumented" and its config is ignored.enable_genai_semconv, spans carry only OpenInference attributes (llm.input_messages.0.message.content, llm.token_count.prompt).delegate_task_to_member tool spans show as ordinary tool calls on the leader with member id and task as arguments. With team.arun() in coordinate mode, members called in one step run concurrently and their spans overlap; sync team.run() runs them sequentially.<agent>.run) and the resumed run (<agent>.continue_run) are two traces and two turns in one session; the approved tool call appears once, in the second. Instrumentor <1.0.8 doesn't wrap continue_run(), so resumed runs appear as loose model/tool calls with no session.gen_ai.tool.call.id, and can't show streaming latency (no TTFT).llm.cost.total (USD, instrumentor >=1.0.10). Providers called directly (OpenAI, Anthropic) return no price, so sessions are unpriced.ERROR, the run span stays OK.maple_ai.session.id on Agno spans: it re-vendors them and loses decoding. Agno's own session.id is what Maple reads.MapleTechLabs/maple
27a645c
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.