Goal: every conversation = one Maple Agent Session. Each agent.run() = one turn (one trace) with transcript, chat spans with tokens, execute_tool spans with args/results, failed tools marked failed, sub-agents in their own lanes.
Mechanism: Pydantic AI's native OTel instrumentation (scope pydantic-ai, GenAI semconv on span attributes). No extra instrumentation package. Maple reads gen_ai.conversation.id as the session key for this framework.
Step 0: Detect
- Pydantic AI version:
python -c "import pydantic_ai; print(pydantic_ai.__version__)"(or readpyproject.toml/uv.lock/requirements*.txt).- Need 2.x (tested 2.51.0). 1.x has no
conversation_id=: tell the user to upgrade; do not work around it. ToolFailedneeds >= 2.16.
- Need 2.x (tested 2.51.0). 1.x has no
- Existing OTel setup. Search for
TracerProvider(,set_tracer_provider,logfire.configure,opentelemetry-instrument,sentry_sdk.init,instrument_all,Instrumentation(,.instrument =.- Logfire already configured → use the Logfire path (Step 2b).
- Another
TracerProviderexists → add aBatchSpanProcessor(OTLPSpanExporter())to it; do NOT create a second provider. - Nothing → Step 2a.
- Find: every
agent.run(/run_sync(/run_stream(/iter(call, where the chat/thread id lives in the request, everyAgent(construction, and every tool that calls another agent'srun(). - Other instrumentors on the same model client (
logfire.instrument_openai,OpenAIInstrumentor, OpenLLMetryTraceloop.init, a globallogfire.instrument_httpx()) → they double-trace model calls. Keep Pydantic AI's; ask before removing the others if they serve something else.logfire.instrument_httpx(client)on a non-model client (tool HTTP calls) is fine to keep.
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. - Key read from a secret env var in code: 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; no bareKeyErroron import, no header without a key. - A 401
ingest_unauthorized("Invalid ingest key") with a key you trust usually means the key belongs to the other region (keys are region-bound): try the other endpoint.
Env vars (the exporter reads them when it is built; it appends /v1/traces). If the app loads .env (load_dotenv()), call it at the top of the tracing module, before the exporter or logfire.configure(); otherwise the exporter silently targets localhost:4318 with no key.
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
Step 2a: Install + init (plain OpenTelemetry, default)
Add with the repo's package manager (uv/poetry/pip):
pip install "pydantic-ai-slim[openai]>=2.51" "opentelemetry-sdk>=1.45" "opentelemetry-exporter-otlp-proto-http>=1.45"
Keep the project's existing pydantic-ai extras; only add the two OTel packages if pydantic-ai is already installed.
Create tracing.py (adapt service name / environment to the project):
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 pydantic_ai import Agent, InstrumentationSettings
provider = TracerProvider(
resource=Resource.create(
{"service.name": "support-agent", "deployment.environment.name": "production"}
)
)
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
Agent.instrument_all(
InstrumentationSettings(
tracer_provider=provider,
include_content=True,
include_binary_content=False,
)
)
- Import it first in the entry point (app module,
main.py, worker). It must run before the firstagent.run(); agents constructed earlier are still covered. - Existing provider: skip creating one; add the processor to it and call
Agent.instrument_all(InstrumentationSettings(include_content=True, include_binary_content=False))(uses the global provider). - Do not pass
version=. Default 5 is the tested format; 2-4 are deprecated. - Set a real
service.name(never leaveunknown_service).
Step 2b: Logfire path (only if the project already uses Logfire)
Keep the Step 1 env vars; they must be in os.environ before logfire.configure() runs. Logfire then adds OTLP span, metric and log exporters for this endpoint; pass metrics=False to logfire.configure() if only traces are wanted.
Do not install the Step 2a OTel packages: logfire already depends on the SDK and the OTLP/HTTP exporter, and logfire 5.1.x pins opentelemetry-sdk<1.45, so adding opentelemetry-sdk>=1.45 makes the install unresolvable. Tested with logfire 5.1.1 (OTel SDK 1.44.0).
import logfire
TOOL_CONTENT = {"gen_ai.tool.call.arguments", "gen_ai.tool.call.result", "final_result"}
def keep_tool_content(match: logfire.ScrubMatch):
if len(match.path) > 1 and match.path[1] in TOOL_CONTENT:
return match.value
logfire.configure(
service_name="support-agent",
environment="production", # adapt to the project
send_to_logfire=False, # keep the project's existing value as is (incl. 'if-token-present'); False only if unset
scrubbing=logfire.ScrubbingOptions(callback=keep_tool_content),
)
logfire.instrument_pydantic_ai()
- Default scrubbing replaces tool args/results containing
session,auth,password,cookie,secret,api key... with[Scrubbed due to '<word>'].gen_ai.conversation.idand message attributes are exempt. If the project already has a scrubbing config, merge the callback into it instead of replacing it. - Do not also call
Agent.instrument_all(...)with another provider.
Step 3: Session id (required)
Pydantic AI resolves gen_ai.conversation.id per run as: explicit conversation_id= > id on the last message of message_history > fresh UUID7. Pass it explicitly on EVERY run call, from the app's chat/thread/conversation id:
result = await agent.run(text, conversation_id=chat_id, message_history=history)
async with agent.run_stream(text, conversation_id=chat_id, message_history=history) as run:
async for delta in run.stream_text(delta=True):
yield delta
- Same for
run_sync(,iter(,run_stream_events(, and the resume run that sendsDeferredToolResults. - The id must be stable per conversation and unique across conversations. No process-wide constants, no
uuid4()per request, no module-level default. - No id available in the app → ask the user where the conversation boundary is; if it's a single-shot script, generate one uuid per conversation (not per run) and reuse it.
- Streaming: keep the
async withopen until the stream is consumed (in FastAPI, inside the generator passed toStreamingResponse), or theinvoke_agentspan ends early.
Step 4: Content
- Content is on by default (
include_content=True):gen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructionsonchatspans; tool args/results onexecute_toolspans. Maple's transcript needs these. - Always set
include_binary_content=False(base64 media repeats in every laterchatspan). - User wants content off →
include_content=Falseglobally, or per agent:Agent(..., capabilities=[Instrumentation(settings=InstrumentationSettings(include_content=False))])(from pydantic_ai.capabilities import Instrumentation; omittracer_providerto use the global one). Tell them the transcript keeps roles but no text. - Logfire scrubbing does not redact message content. For pattern redaction of prompts, recommend an OTel Collector
redaction/transformprocessor.
Step 5: Tools, errors, sub-agents
- Name every agent:
Agent(..., name="support"). Unnamed agents becomeagentand share one lane. - Tool failures the model should see:
raise ToolFailed("message")(from pydantic_ai import ToolFailed, >= 2.16). Span ERROR, message recorded asgen_ai.tool.call.result, run continues, no retry budget used.ModelRetryalso marks the span ERROR (one failed call per retry): use it only for real retries.- Replace
return {"error": ...}/return "Error: ..."in tools withraise ToolFailed(...)only where the user agrees; returned errors show as successful calls. - Uncaught exceptions mark the tool span and the run ERROR and abort the run.
- Delegation (a tool that calls another agent): pass the caller's id and usage to EVERY nested run:
@orchestrator.tool
async def research_weather(ctx: RunContext[None], city: str) -> str:
"""Delegate to the weather worker."""
result = await weather_worker.run(
f"What is the current weather in {city}?",
usage=ctx.usage,
conversation_id=ctx.conversation_id,
)
return result.output
Without conversation_id=ctx.conversation_id each delegate mints a UUID7, so one trace carries several ids and Maple may file the trace under the wrong session or split the turn.
4. Sequential pipelines of top-level runs (orchestrator run, then summary run): pass the same conversation_id= to each; each run is its own trace/turn in the same session.
Step 6: Flush
- The SDK flushes at normal interpreter exit. Add an explicit flush where that doesn't happen:
- AWS Lambda / Cloud Functions / Cloud Run jobs:
trace.get_tracer_provider().force_flush()in afinallyin the handler. - Scripts, CLIs, one-shot jobs:
provider.shutdown()at the end (finally). - Celery/RQ/multiprocessing workers, notebooks:
force_flush()after each task/cell that runs an agent. - Logfire:
logfire.force_flush()/logfire.shutdown().
- AWS Lambda / Cloud Functions / Cloud Run jobs:
Step 7: Verify
Run one real conversation: 2+ messages with the same id, at least one tool call, one streamed message if the app streams, and a sub-agent call if the app delegates. If the app has no scriptable entry point (server, UI only), write a small driver for this run: one conversation id, 2+ turns, a tool call, flush before exit. Then check (Maple → Agent Sessions, filter by the service name; wait ~30 s):
- Exactly one session per conversation; session id = the id you passed (not a UUID7 you didn't create). A second conversation is a different session.
- Framework shows as Pydantic AI.
- One turn per
run(); transcript shows user prompts, assistant replies and tool calls. - Spans:
invoke_agent <name>,chat <model>,execute_tool <tool>; eachchat/execute_toolis inside its run'sinvoke_agentin the same trace. - Every
chatspan has input and output tokens, including the streamed one. - Tool calls have their real names, arguments and results.
- A failing tool is marked failed with its message; successful tools are not.
- Sub-agents appear as separate lanes with their
name=, all under the caller's session. - Cost shows on sessions whose model Pydantic AI can price; "unpriced" otherwise.
- No attribute contains an API key or
Bearertoken.
With the Maple MCP: list_agent_sessions with search=<conversation id> returns one row.
Quick local cue: Pydantic AI 2.51 prints an observability: off banner on the first run when no instrumentation is set; it disappears once instrument_all() (or logfire.instrument_pydantic_ai()) has run.
Without Maple access (or with MAPLE_TEST), both must hold; silence alone proves nothing (no spans is silent too):
- The run exits with no
Failed to export/ 401 lines on stderr. - A console exporter shows the spans with
gen_ai.conversation.id, identical across runs of one conversation and on delegate spans. Add it temporarily: plain pathprovider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter())); Logfire pathlogfire.configure(..., additional_span_processors=[SimpleSpanProcessor(ConsoleSpanExporter())])(both fromopentelemetry.sdk.trace.export). On the Logfire path its own console prints<agent> run/running tool: <x>; exported span names are stillinvoke_agent/execute_tool.
Known behavior (tell the user when relevant)
- Tool failure matrix:
ToolFailed→ span ERROR, model sees message, run continues;ModelRetry→ ERROR, one failed call per retry; other exception → ERROR, run raises, failed turn;return {"error": ...}→ UNSET, shows as successful call. - Approval-gated tools (
requires_approval=True) pause the run with no tool span; theexecute_toolspan appears only in the resumed run (the one sendingDeferredToolResults). Sameconversation_id=→ paused and resumed runs are two turns of one session, both labeled with the original request. - Delegation rendering: an
execute_tool <x>span whose only child isinvoke_agent <worker>shows as a delegation; the tool's args/result become the lane's input/output. Several delegation tools in one model reply run concurrently, so lanes overlap in time. include_content=Falsealso drops exception messages (only the exception type is kept).- Instrumentation
version: 5 default (tested); 2-4 deprecated withPydanticAIDeprecationWarning(version 2 used different span names); 6 is opt-in and sends tool results withrole: "tool". Fix the warning by removingversion=. - Tokens:
chatspans carrygen_ai.usage.input_tokens,gen_ai.usage.output_tokens, plusgen_ai.usage.cache_read.input_tokens/gen_ai.usage.cache_creation.input_tokenswhen the provider caches.invoke_agentcarries run totals undergen_ai.aggregated_usage.*, which Maple does not add to the session total. Delegate tokens stay on the delegate's spans. - Streaming: Pydantic AI requests usage on OpenAI-compatible streams (
stream_options.include_usage), so streamed calls have tokens. Time-to-first-chunk is recorded under a key Maple doesn't read yet. - Cost: Pydantic AI writes
operation.costonchatspans when it can price the model, and Maple shows it. Maple never prices tokens itself; a model Pydantic AI can't price shows as unpriced. - Logfire with
send_to_logfire=True(or a Logfire token in env) sends to both Logfire and Maple. - Provider extras: swap
[openai]foranthropic,google,openrouter, ...; the fullpydantic-aipackage also works. - Duplicate spans: another instrumentor on the model client (Logfire
instrument_openai(), OpenInference, OpenLLMetry) double-traces model calls; keep Pydantic AI's. - Export 413 / huge
chatspans: binary content recorded as base64; setinclude_binary_content=False.
Do not
- Do not keep
event_mode="logs": content moves to logs, which Maple doesn't read (empty transcript). - Do not add
session.idormaple_ai.session.id: Maple readsgen_ai.conversation.idhere;maple_ai.session.idre-vendors the span.