Goal: one conversation = one Maple Agent Session, with transcript, model calls, tool calls (failures marked), tokens and (on OpenRouter) cost.
Step 0: Detect
haystack-aiversion:python -c "import haystack; print(haystack.__version__)". Need>=3.0; guide verified on 3.2. For 2.x, stop and tell the user this skill targets Haystack 3.- Find where Agents/Pipelines run (
Agent(,Pipeline(,.run(,AgentTool(,PipelineTool() and where a chat/thread id is available per request. - Existing OTel: grep for
TracerProvider(,set_tracer_provider,configure_otel,logfire.configure,opentelemetry-instrument. If a provider exists, reuse it: only add the Maple exporter (if not already exporting to Maple) andenable_tracing(...). Never create a second provider. - Remove/skip
HaystackInstrumentor().instrument()(OpenInference) and OpenLLMetry's Haystack instrumentor if present: they double every model call. Remove any existingenable_tracing(OpenTelemetryTracer(...))orOpenTelemetryConnectorcomponent and replace with Step 2.
Step 1: Key and region
- US endpoint
https://ingest.maple.dev; EU endpointhttps://ingest.eu.maple.dev. HeaderAuthorization=Bearer <key>. Protocolhttp/protobuf. - 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. - Private
maple_sk_keys never go in browser code. - Follow the repo's secret/env convention (
.env, settings module, deployment env). If none exists, 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.
Step 2: Install and init
pip install "haystack-ai>=3.2" "opentelemetry-haystack>=1.0" "opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45"
Use the repo's package manager (uv add, poetry add, requirements file). Write this file verbatim as maple_haystack.py in the app's package:
"""Haystack tracer that adds the OpenTelemetry GenAI attributes Maple reads."""
import json
import logging
from collections.abc import Iterator
from contextlib import contextmanager
from contextvars import ContextVar
from typing import Any
from haystack.dataclasses import ChatMessage
from haystack_integrations.tracing.opentelemetry import OpenTelemetrySpan, OpenTelemetryTracer
from opentelemetry import trace
from opentelemetry.trace import StatusCode
logger = logging.getLogger(__name__)
_conversation_id: ContextVar[str | None] = ContextVar("maple_conversation_id", default=None)
@contextmanager
def conversation(conversation_id: str) -> Iterator[None]:
"""Every Haystack run inside this block joins the same Maple session."""
token = _conversation_id.set(conversation_id)
try:
yield
finally:
_conversation_id.reset(token)
def _messages(messages: list[ChatMessage]) -> str:
out = []
for m in messages:
parts: list[dict[str, Any]] = [{"type": "text", "content": t} for t in m.texts]
parts += [{"type": "tool_call", "id": c.id, "name": c.tool_name, "arguments": c.arguments} for c in m.tool_calls]
parts += [{"type": "tool_call_response", "id": r.origin.id, "response": r.result} for r in m.tool_call_results]
out.append({"role": m.role.value, "parts": parts})
return json.dumps(out, default=str)
class MapleSpan(OpenTelemetrySpan):
def __init__(self, span: trace.Span, operation: str | None, content: bool) -> None:
super().__init__(span)
self._operation = operation
self._content = content
def set_content_tag(self, key: str, value: Any) -> None:
try:
if self._operation == "chat":
self._chat(key, value)
elif self._operation == "execute_tool":
self._tool(key, value)
except Exception: # a tracing bug must never fail the agent run
logger.exception("maple_haystack: could not map %s", key)
if self._content:
self.set_tag(key, value)
def _chat(self, key: str, value: Any) -> None:
if key.endswith(".input") and self._content:
messages = value["messages"]
system = [{"type": "text", "content": m.text} for m in messages if m.is_from("system")]
if system:
self._span.set_attribute("gen_ai.system_instructions", json.dumps(system))
self._span.set_attribute("gen_ai.input.messages", _messages([m for m in messages if not m.is_from("system")]))
elif key.endswith(".output"):
replies = value["replies"]
meta = replies[0].meta
usage = meta.get("usage") or {}
prompt_details = usage.get("prompt_tokens_details") or {}
attributes = {
"gen_ai.response.model": meta.get("model"),
"gen_ai.response.finish_reasons": meta.get("finish_reason"),
"gen_ai.usage.input_tokens": usage.get("prompt_tokens"),
"gen_ai.usage.output_tokens": usage.get("completion_tokens"),
"gen_ai.usage.cache_read.input_tokens": prompt_details.get("cached_tokens"),
"gen_ai.usage.cache_write.input_tokens": prompt_details.get("cache_write_tokens"),
"gen_ai.usage.reasoning.output_tokens": (usage.get("completion_tokens_details") or {}).get("reasoning_tokens"),
"gen_ai.usage.cost": usage.get("cost"), # OpenRouter prices every call; other providers leave this out
}
if self._content:
attributes["gen_ai.output.messages"] = _messages(replies)
self._span.set_attributes({k: v for k, v in attributes.items() if v is not None})
def _tool(self, key: str, value: Any) -> None:
if key.endswith(".output") and isinstance(value, dict) and "error" in value:
# Haystack records a failed tool as {"error": ...} and leaves the span status unset
self._span.set_status(StatusCode.ERROR, str(value["error"]) if self._content else "Tool invocation failed")
self._span.set_attribute("error.type", "ToolInvocationError")
if self._content:
attribute = "gen_ai.tool.call.arguments" if key.endswith(".input") else "gen_ai.tool.call.result"
# Tool results arrive as strings: write them as-is, so a JSON result stays a JSON object
self._span.set_attribute(attribute, value if isinstance(value, str) else json.dumps(value, default=str))
class MapleHaystackTracer(OpenTelemetryTracer):
def __init__(self, tracer: trace.Tracer, *, content: bool = True) -> None:
super().__init__(tracer)
self._content = content
@contextmanager
def trace(self, operation_name: str, tags: dict[str, Any] | None = None, parent_span: Any = None) -> Iterator[MapleSpan]:
tags = dict(tags or {})
attributes: dict[str, str] = {}
operation = None
if operation_name == "haystack.agent.run":
# The Agent span has no name: use the pipeline component or the AgentTool that runs it
parent = getattr(trace.get_current_span(), "attributes", None) or {}
attributes["gen_ai.operation.name"] = "invoke_agent"
attributes["gen_ai.agent.name"] = parent.get("haystack.component.name") or parent.get("gen_ai.tool.name") or "agent"
elif operation_name == "haystack.agent.step.llm" or str(tags.get("haystack.component.type", "")).endswith("ChatGenerator"):
operation = attributes["gen_ai.operation.name"] = "chat"
elif operation_name == "haystack.agent.step.tool":
operation = attributes["gen_ai.operation.name"] = "execute_tool"
attributes["gen_ai.tool.name"] = tags["haystack.tool.name"]
if conversation_id := _conversation_id.get():
attributes["gen_ai.conversation.id"] = conversation_id
if not self._content:
tags.pop("haystack.pipeline.input_data", None) # a plain tag, not gated by Haystack's content switch
with self._tracer.start_as_current_span(operation_name, attributes=attributes) as raw_span:
span = MapleSpan(raw_span, operation, self._content)
span.set_tags(tags)
yield span
Init once at startup, before the first pipeline.run()/agent.run() (no import-order constraint beyond that):
from haystack import tracing
from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from maple_haystack import MapleHaystackTracer
provider = TracerProvider(
resource=Resource.create({"service.name": "<service>", "deployment.environment.name": "<env>"})
)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
tracing.enable_tracing(MapleHaystackTracer(trace.get_tracer("haystack")))
Env:
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev # EU: https://ingest.eu.maple.dev; /v1/traces is appended
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
- The app loads
.env(load_dotenv(),--env-file): call it beforeOTLPSpanExporter()is built. Otherwise the exporter silently targetslocalhost:4318with no key. - Tracer name must be
"haystack"(instrumentation scope = how Maple labels the vendor Haystack). - Set a real
service.name; never leaveunknown_service.
Step 3: Session id
Haystack has no conversation concept; the app owns the message history. Wrap every run in conversation(<stable chat id>):
from maple_haystack import conversation
with conversation(chat_id):
result = pipeline.run({"assistant": {"messages": [*history, ChatMessage.from_user(text)]}})
history = [m for m in result["assistant"]["messages"] if not m.is_from("system")] # Agent re-adds its system prompt
chat_id= the id the app already stores for the chat/thread (DB id, frontend thread id). Never a per-request UUID, never a process-wide constant.- Wrap the run call itself (inside the request handler). It is a ContextVar: request-scoped and carried into Haystack's tool threads.
- It writes
gen_ai.conversation.idon every span. That is the only session key Maple reads for Haystack (session.idis ignored).
Step 4: Content
- Default
MapleHaystackTracer(..., content=True)writesgen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructions,gen_ai.tool.call.arguments/.result, plus Haystack's ownhaystack.*content tags. content=Falsekeeps model, tokens, cost, finish reason, tool names and failures; drops messages, tool args/results,haystack.*content tags and the ungatedhaystack.pipeline.input_datatag. Use it if the user asks for no prompts/PII in telemetry. It also replaces a failed tool's status message, which quotes the tool's arguments, with a generic one.HAYSTACK_CONTENT_TRACING_ENABLEDis irrelevant with this tracer; don't add it.
Step 5: Tools, errors, sub-agents
- Tool spans:
haystack.agent.step.tool→execute_tool+gen_ai.tool.name. A tool that raises becomes{"error": ...}in Haystack; the tracer sets statusError+error.type=ToolInvocationError. Don't catch exceptions inside tools just to return strings; let them raise (or return{"error": ...}). - Agent name (
gen_ai.agent.name, needed for Maple lanes) = pipeline component name, or theAgentToolname, elseagent. If the app callsagent.run()directly for its main agent, prefer running it as aPipelinecomponent with a meaningful name (only if that's a small change; otherwise leave it and mention theagentname). - Sub-agents: use
AgentTool(agent=..., name="<worker>", description=...)(Haystack ≥3.1) orPipelineTool; each worker gets its own name/lane. Keep them under the sameconversation()block. - Streaming with
OpenAIChatGenerator: addgeneration_kwargs={"stream_options": {"include_usage": True}}or the streamed turn has no tokens. OpenRouter always sends usage. - Token mapping reads OpenAI-style
meta["usage"]keys (prompt_tokens,completion_tokens,*_details). For another generator, print one reply'smeta["usage"]; if keys differ, extend_chat(). - Cost: only OpenRouter reports
usage.cost→gen_ai.usage.cost. Other providers show "unpriced" in Maple; say so to the user.
Step 6: Flush
Scripts, CLIs, notebooks, jobs, serverless: flush before exit.
try:
run()
finally:
provider.force_flush()
provider.shutdown()
Servers: provider.shutdown() in the shutdown hook (FastAPI lifespan, atexit).
Step 7: Verify
Run one real conversation (≥2 turns, one tool call). No scriptable entry point (server, REPL, UI only) → write a small driver: one conversation() id, 2+ turns, at least one tool call, flush before exit. Without Maple access, both must hold: the run exits with no export errors on stderr (Failed to export, 401 lines), AND a local console/in-memory exporter shows the spans below. Silence alone proves nothing (no spans is silent too). With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row. Check:
- Every span has
service.nameset; spans' scope ishaystack. - Each
haystack.agent.step.llmspan hasgen_ai.operation.name=chat,gen_ai.response.model,gen_ai.usage.input_tokens,gen_ai.usage.output_tokens(also on the streamed turn). gen_ai.input.messages/gen_ai.output.messagesare JSON arrays of{role, parts}(content on).- Each tool call has one
haystack.agent.step.toolspan with the realgen_ai.tool.name; a failing tool has statusErroranderror.type; successful tools don't. haystack.agent.runhasgen_ai.operation.name=invoke_agentand a distinctgen_ai.agent.nameper sub-agent.- Every trace of one conversation carries the same
gen_ai.conversation.id; a second conversation carries a different one. - No model call appears twice (no second instrumentor).
- No attribute contains an API key or
Bearer. - In Maple: one session per conversation labelled Haystack, turns = runs, transcript non-empty, LLM/tool counts match, failed tool counted, cost shown (OpenRouter) or unpriced.
Tell the user: known gaps are no gen_ai.provider.name, no gen_ai.response.id, no tool call id on tool spans, and cost only on OpenRouter.
Expected trace
haystack.pipeline.run
haystack.component.run assistant
haystack.agent.run invoke_agent, agent "assistant"
haystack.agent.step
haystack.agent.step.llm chat, openai/gpt-4o-mini
haystack.agent.step.tool execute_tool, get_weather
haystack.agent.step
haystack.agent.step.llm chat
Known behaviours (expected; explain if the user asks)
- Haystack looks up the active tracer on every span, so there is no import-order trap for
enable_tracing(). Without this tracer,HAYSTACK_CONTENT_TRACING_ENABLEDis read once at the firstimport haystack; setting it later silently records nothing. - Tools of one step run in parallel threads; their spans sit side by side under the step.
- Haystack wraps a raised tool exception in
ToolInvocationError, feeds the text back to the model, writes{"error": "..."}as the tool output and leaves the spanUnset. The tracer setsErrorregardless ofraise_on_tool_invocation_failure. - An Agent inside a
PipelineToolis named after its component in the inner pipeline. - Approval gates (
ConfirmationHookatbefore_tool) stay in the same run and session. A rejected call never reaches the tool, so it has no tool span; the model sees the rejection as a tool result in the transcript. An Agent with hooks also gets ahaystack.agent.hookspan before its tool calls; likehaystack.agent.step, it carries no model or tool attributes and isn't counted. haystack.pipeline.input_dataon the root span is a plain tag Haystack's own content switch never gated;content=Falsedrops it. To redact rather than drop message content, filter values in_messages().
Do not
- Don't set
maple_ai.session.idfor Haystack;gen_ai.conversation.idviaconversation()is the key.