Goal: every conversation = one Maple Agent Session. Each agent.run() / workflow.run() = one turn (one trace) with transcript, one model span per model call with tokens, FunctionTool.acall tool spans with results, failed tools marked failed, sub-agents in their own lanes.
Mechanism: openinference-instrumentation-llama-index (scope openinference.instrumentation.llama_index). Maple reads the session (session.id from using_session), transcript, tokens and tool calls from its spans. TraceConfig(enable_genai_semconv=True) is recommended: it also emits standard GenAI attributes (gen_ai.*, incl. gen_ai.conversation.id).
Step 0: Detect
- Versions:
python -c "import llama_index.core as c; print(c.__version__)"(orpyproject.toml/uv.lock/requirements*.txt).- Need llama-index-core >= 0.14.19 (tested 0.14.25). Older: the instrumentor logs
DependencyConflictand does nothing. Tell the user to upgrade. - Python >= 3.10.
- Need llama-index-core >= 0.14.19 (tested 0.14.25). Older: the instrumentor logs
- Existing tracing. Search for
LlamaIndexOpenTelemetry,llama_index.observability.otel,LlamaIndexInstrumentor,set_global_handler,TracerProvider(,set_tracer_provider,opentelemetry-instrument,logfire.configure,sentry_sdk.init,langfuse,phoenix.otel.register.LlamaIndexOpenTelemetry(nativellama-index-observability-otel) present → replace it with Step 2 (it puts content in span events, loses reply and usage on streamed calls, ignoresOTEL_EXPORTER_OTLP_*). Never run both: every span doubles. Ask before removing if it also feeds another backend.LlamaIndexInstrumentoralready used (Phoenix/Langfuse/Arize) → keep it, reuse its provider, addconfig=TraceConfig(enable_genai_semconv=True)and the Maple processor to that provider.- Another
TracerProviderexists → add the Maple processor chain to it; do NOT create a second provider.
- Other instrumentors on the model client (
openinference-instrumentation-openai,-litellm, OpenLLMetryTraceloop.init,logfire.instrument_openai) → duplicate model spans with their own usage. Remove for LlamaIndex models (ask if they serve other code). - Find: every
agent.run(/workflow.run(/AgentWorkflow(call, where the chat/thread id lives in the request, everyFunctionAgent(/ReActAgent(/CodeActAgent(construction, every tool that runs another agent, everyctx.wait_for_event((HITL). - LLM class:
OpenAILike/OpenRouterneedis_function_calling_model=Trueor tools silently never run (no tool spans). Check it's set.
Step 1: Key and region
- US:
https://ingest.maple.dev. EU:https://ingest.eu.maple.dev. - Header:
Authorization=Bearer <key>. - Key given in the prompt → use it.
- No key → use the literal
MAPLE_TEST(ingest accepts and discards it) and tell the user to replace it with their key from Settings → Ingestion. - Never put a private
maple_sk_key in browser code. - Follow the repo's secret/env convention (
.env, settings module, secret manager) if it has one. Otherwise inline is acceptable: ingest keys are write-only. - Building the header in code from an env var: when it is unset, log one warning (
MAPLE_INGEST_KEY is not set; Maple telemetry export is disabled) and skip the Maple exporter so the app runs normally. Never raise or exit over the key, and never sendBearer None(opaque 401) or hit a bareKeyErroron import. Or inline the key when the repo has no env convention. - 401
ingest_unauthorized/ "Invalid ingest key" with a key you trust: keys are region-bound, so it usually belongs to the other region. Try the other endpoint.
Env vars (OTLPSpanExporter() reads them and appends /v1/traces):
OTEL_SERVICE_NAME=support-agent
OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=production
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
If you pass OTLPSpanExporter(endpoint=...) in code, it must end in /v1/traces (no auto-append).
Step 2: Install + init
Add with the repo's package manager (uv/poetry/pip):
pip install "llama-index-core>=0.14.25" "openinference-instrumentation-llama-index>=4.5.2" \
"opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45"
Create tracing.py verbatim (adapt nothing except where noted):
# tracing.py
from llama_index.core.instrumentation.dispatcher import active_instrument_tags
from openinference.instrumentation import TraceConfig
from openinference.instrumentation.llama_index import LlamaIndexInstrumentor
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
LLM_METHODS = (".chat", ".achat", ".stream_chat", ".astream_chat",
".complete", ".acomplete", ".stream_complete", ".astream_complete")
class LlamaIndexForMaple(SpanProcessor):
"""Sits in front of the exporter: one span per model and tool call, agent names, no false HITL failures."""
def __init__(self, exporter_processor: SpanProcessor):
self._next = exporter_processor
self._open_llm_spans = {}
def on_start(self, span, parent_context=None):
# instrument_tags({"gen_ai.agent.name": ...}) becomes an attribute, so sub-agents get lanes
agent_name = active_instrument_tags.get().get("gen_ai.agent.name")
if agent_name:
span.set_attribute("gen_ai.agent.name", agent_name)
if span.name.endswith((".call_tool", ".aggregate_tool_results")):
# agent workflow steps around the tool span; by name alone Maple would count them as tool calls
span.set_attribute("gen_ai.operation.name", "invoke_workflow")
if span.name.endswith(LLM_METHODS):
self._open_llm_spans[span.context.span_id] = span
self._next.on_start(span, parent_context)
def on_end(self, span):
self._open_llm_spans.pop(span.context.span_id, None)
if span.name.endswith("._prepare_chat_with_tools"):
return # builds the request; never calls the model
if (span.status.description or "").startswith("WaitingForEvent"):
return # ctx.wait_for_event() suspends the tool and replays it later; not a failure
outer = self._open_llm_spans.get(span.parent.span_id) if span.parent else None
if outer is not None and outer.name == span.name:
outer.set_attributes(span.attributes) # the inner twin holds the messages and usage
return
self._next.on_end(span)
def shutdown(self):
self._next.shutdown()
def force_flush(self, timeout_millis=30000):
return self._next.force_flush(timeout_millis)
provider = TracerProvider() # reads OTEL_SERVICE_NAME and OTEL_RESOURCE_ATTRIBUTES
provider.add_span_processor(LlamaIndexForMaple(BatchSpanProcessor(OTLPSpanExporter())))
trace.set_tracer_provider(provider)
LlamaIndexInstrumentor().instrument(
tracer_provider=provider,
config=TraceConfig(enable_genai_semconv=True),
)
- Import
tracingfirst in the entry point (app module,main.py, worker).instrument()must run before the firstagent.run(). - The app loads
.env(load_dotenv(),--env-file): callload_dotenv()at the top oftracing.py, before the provider is built. Otherwise the exporter silently targetslocalhost:4318with no key. - Existing provider: skip
TracerProvider()/set_tracer_provider; callexisting.add_span_processor(LlamaIndexForMaple(BatchSpanProcessor(OTLPSpanExporter())))and passtracer_provider=existing. - The exporter MUST be added through
LlamaIndexForMaple, never directly: without it every model call counts 2-3x in Maple (_prepare_chat_with_tools+ nested same-nameastream_chat/achatspans, all OpenInference kind LLM) and every tool call 3x (call_tool/aggregate_tool_resultsstep spans are classified as tools by name). - No
service.namedefault is acceptable: setOTEL_SERVICE_NAME(neverunknown_service).
Step 3: Session id (required)
Nothing in LlamaIndex sets a conversation id; Context and llamaindex.run_id never reach Maple as one. Wrap every agent.run(...) / workflow.run(...) CALL in using_session(<app conversation id>); add instrument_tags with the agent name in the same with:
from llama_index.core.agent.workflow import AgentStream
from llama_index.core.instrumentation.dispatcher import instrument_tags
from llama_index.core.workflow import Context
from openinference.instrumentation import using_session
contexts: dict[str, Context] = {}
async def handle_message(conversation_id: str, text: str):
if conversation_id not in contexts:
contexts[conversation_id] = Context(agent)
ctx = contexts[conversation_id]
with using_session(conversation_id), instrument_tags({"gen_ai.agent.name": agent.name}):
handler = agent.run(user_msg=text, ctx=ctx)
async for event in handler.stream_events():
if isinstance(event, AgentStream):
yield event.delta
await handler
- Only the
run()call must be inside thewith(tasks start there and inherit the contextvars). Consume the stream /await handleroutside it; do notyieldinside thewithin async generators. - The id must be stable per conversation and unique across conversations: the app's chat/thread id. No
uuid4()per request, no constant, no module-level default. No id available → ask the user where the conversation boundary is; for a one-shot script, one uuid per conversation reused across its turns. - One
Contextper conversation (a sharedContextshares memory across users). - HITL: keep
handler.ctx.send_event(HumanResponseEvent(...))on the same handler; the resumed step stays in the same trace and session. - Do not use
session.id/maple_ai.session.idattributes of your own;using_sessionalready writessession.id(andgen_ai.conversation.idwithenable_genai_semconv).
Step 4: Content
- On by default:
gen_ai.input.messages(system + history + tool results),gen_ai.output.messages(incl. tool_call parts), tool results. Maple's transcript needs them. - User wants content off →
TraceConfig(enable_genai_semconv=True, hide_inputs=True, hide_outputs=True)(orhide_input_text/hide_output_textto keep structure). Env equivalentsOPENINFERENCE_HIDE_*. Tell them the transcript will be empty. - Pattern redaction (emails, cards): recommend an OTel Collector
redactionprocessor.
Step 5: Tools, errors, sub-agents
- Give every agent a
name=and wrap each agent'srun()ininstrument_tags({"gen_ai.agent.name": agent.name})(the processor copies it onto every span). No tag = no agent facet, no lanes. - Multi-agent via custom
Workflow: run the whole workflow insideusing_session(...); inside steps call sub-agents like this:
async def run_agent(agent: FunctionAgent, message: str) -> str:
with instrument_tags({"gen_ai.agent.name": agent.name}):
handler = agent.run(user_msg=message)
return str(await handler)
Parallel fan-out (several ctx.send_event calls to separate steps or to a @step(num_workers=N), joined with ctx.collect_events) stays in one trace with the session and agent tags.
3. Agents-as-tools: put the same instrument_tags block inside the tool function around the sub-agent's run().
4. AgentWorkflow handoffs: one AgentWorkflow.run span, no per-agent spans, so no lanes; every span carries the root agent's tag and the handoff is a handoff tool call. Tag the call with the root agent's name. If the user needs lanes, suggest running agents via workflow steps or tools (ask first; it changes app behavior).
5. Tool failures: a tool that raises → FunctionTool.acall status ERROR with the exception message (Maple counts it). Tools that return "Error: ..." look successful: convert to raise only where the user agrees.
6. HITL (ctx.wait_for_event): the first, suspended FunctionTool.acall ends ERROR WaitingForEvent: ...; the processor drops it. Nothing to add.
7. Known, unfixable here: no gen_ai.tool.call.id on tool spans.
Step 6: Tokens, cost, streaming
- Tokens come from the provider response on the model span.
FunctionAgentstreams by default; OpenAI-API streams only include usage when asked. ForOpenAI/OpenAILikemodels not on OpenRouter add:
llm = OpenAI(model="gpt-4o-mini", additional_kwargs={"stream_options": {"include_usage": True}})
(LlamaIndex strips it from non-streaming requests.) OpenRouter sends usage without it.
- Streamed model spans end when the stream is handed back (~1 ms), so their duration is not model latency (Maple's session inference time totals a few ms;
FunctionAgentstreams by default, so this is every call). If the app never streams tokens to users,FunctionAgent(..., streaming=False)gives real model-span durations. Ask before changing it. - Cost: never recorded. Sessions show "unpriced". Do not add pricing code. OpenRouter users can add OpenRouter Broadcast for cost.
Step 7: Flush
- The provider flushes at normal interpreter exit.
- Scripts/CLIs/one-shot jobs:
provider.shutdown()in afinallyat the end. - Serverless handlers, Celery/RQ tasks, notebooks:
provider.force_flush()in afinallyafter each run (from tracing import provider). - FastAPI/long-running servers: nothing extra; optionally
provider.shutdown()in the lifespan shutdown.
Step 8: Verify
Run one real conversation: 2+ messages with the same id, at least one tool call, a failing tool if one exists, and a sub-agent run if the app delegates. No scriptable entry point (server, REPL, UI only) → write a small driver: one conversation id, 2+ turns, at least one tool call, provider.shutdown() before exit. Then check (Maple → Agent Sessions, filter by service name; wait ~30-60 s):
- Exactly one session per conversation, id = the id you passed. A second conversation is a second session. Not
trace:<id>sessions. - One turn per
run(); each trace roots atFunctionAgent.run/AgentWorkflow.run/<YourWorkflow>.run. - Framework shows "LlamaIndex".
- Transcript shows user messages, assistant replies and tool calls.
- LLM call count ≈ real number of model calls (one
<ModelClass>.astream_chat/.achatspan per call; no_prepare_chat_with_toolsspans exported). - Every model span has a model and input/output tokens, including streamed calls.
- Tool call count = real tool calls (only
FunctionTool.acall;call_tool/aggregate_tool_resultscarrygen_ai.operation.name=invoke_workflow). Tool spans carrygen_ai.tool.name(real tool name) and a result; model and tool spans sit under the turn's agent span in the same trace. - A failing tool is failed with its message; successful and HITL-approved tools are not.
- Sub-agents appear as separate lanes with their names, under the caller's session.
- Cost shows "unpriced".
- No attribute contains an API key or
Bearertoken.
Edge cases:
- Native
llama-index-observability-otel(0.7.0,LlamaIndexOpenTelemetry): model settings/prompt only inLLMChatStartEventspan events (Maple ignores events); the end event with reply+usage is dropped on streamed calls; two nested<Model>.astream_chatspans per call; ignoresOTEL_EXPORTER_OTLP_*and defaults toConsoleSpanExporterwithoutspan_exporter=. If a user insists on keeping it and only wants sessions:instrument_tags({"gen_ai.conversation.id": conversation_id})aroundagent.run()groups traces (dotted tag keys become span attributes verbatim). - Span layout the processor fixes:
BaseWorkflowAgent.run_agent_step→<Model>._prepare_chat_with_tools(kind LLM, ~1 ms) +<Model>.astream_chat(OpenAILike override) →<Model>.astream_chat(inner, holds messages+usage). Classes implementing the call directly (OpenAI) produce two spans. Merge only applies to directly nested identical names, soCondensePlusContextChatEngine.chat→OpenAI.chatis untouched. Tokens were never doubled (only innermost has usage), only call counts. OPENINFERENCE_ENABLE_GENAI_SEMCONV=trueis equivalent to the config flag only if set beforeTraceConfigis built.- Tool spans:
FunctionTool.acall(kind TOOL,execute_tool), result is LlamaIndexToolOutputJSON withraw_input/raw_output(real args are inraw_input). On failure thecall_toolstep stays OK becauseFunctionAgenthands the error to the model. - Agents-as-tools: a tool span with one
FunctionAgent.runchild shows as a delegation; tool args/result become the lane's input/output. Maple opens a lane for every agent span whose name differs from its caller's. - Provider:
OpenRouterand everyOpenAILikereportopenai, even for Anthropic models. No response model name orgen_ai.response.idrecorded. - OpenRouter Broadcast for cost: because model spans lack
gen_ai.response.id, nest Broadcast spans under them (https://maple.dev/docs/agent-tracing/openrouter#nest-broadcast-under-your-own-traces) or each call counts twice. - With a persistent
Contextevery model span repeats the whole chat. Maple has no per-attribute limit; ingest accepts requests up to 20 MiB. - Streaming query engines on llama-index-core 0.14.25: the model call of a
StreamingResponseruns after the query span ended, so it lands in a separate trace. Fix pending in OpenInference PR #3841 (https://github.com/Arize-ai/openinference/pull/3841); until released, pinllama-index-core<0.14.25if the app traces streaming query engines. Agents unaffected.
Without Maple access, both must hold: the run exits with no export errors on stderr (Failed to export, 401 lines), AND a temporary SimpleSpanProcessor(ConsoleSpanExporter()) passed into LlamaIndexForMaple shows the agent, model and FunctionTool.acall spans with gen_ai.conversation.id identical on every span of a conversation and gen_ai.agent.name set. Silence alone proves nothing (no spans is silent too). With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row.