Goal: one conversation = one ADK session id = one Maple Agent Session, with the transcript (user, assistant, tool calls and results), model calls, tool calls with arguments/results, failures, and tokens.
ADK emits its own OTel spans (scope gcp.vertex.agent): invocation > invoke_agent {agent} > call_llm > generate_content {model}, plus execute_tool {tool}. No instrumentation package is needed. You add: a tracer provider (Runner apps only), the env vars below, one plugin, one span processor.
TypeScript (@google/adk in package.json): follow references/typescript.md instead of Steps 0-7 below. Step 1 (key and region) applies to both. ADK for TypeScript records content differently (Gemini-shaped JSON on gcp.vertex.agent.* attributes, no generate_content span), so the Python env switches and plugin do not apply there.
Step 0: Detect versions and existing setup
- Read
pyproject.toml/requirements*.txt/uv.lock. Requiregoogle-adk>=2.10; upgrade if lower (before 2.7,gen_ai.systemis alwaysgeminiand failed tools are not marked ERROR). - ADK caps
opentelemetry-sdk<=1.42.1. Never pin a newer OTel package; let the resolver choose. - Find how ADK runs:
adk web/adk api_server(CLI): ADK builds the tracer provider fromOTEL_EXPORTER_OTLP_*env. Do NOT register another provider.Runner(...)in the app's own code (FastAPI, worker, script, notebook): nothing is exported unless the app registers a provider. You must add one.
- Grep for an existing OTel setup:
set_tracer_provider,TracerProvider(,logfire.configure,sentry_sdk.init,maybe_set_otel_providers,opentelemetry-instrument. If one exists, add the Maple processor to that provider instead of creating a second one. - Grep for double instrumentation and remove it if it only exists for tracing:
litellm.callbackscontaining"otel",openinference-instrumentation-google-adk/GoogleADKInstrumentor,openinference-instrumentation-litellm,openinference-instrumentation-openai,opentelemetry-instrumentation-openai-v2. Ask the user before removing something another backend relies on.
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 a key from Settings → Ingestion. - Never put a private
maple_sk_key in browser code. - Follow the repo's secret/env convention (
.env, settings module, deployment env). If there is none, inline values are acceptable: ingest keys are write-only. - Key read from env 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.
Step 2: Install and initialize
pip install "google-adk>=2.10" opentelemetry-exporter-otlp-proto-http
Add litellm only if the app uses google.adk.models.lite_llm.LiteLlm. Use the repo's package manager (uv add, poetry add).
Env vars (all runtimes, including adk web/api_server):
OTEL_SERVICE_NAME=<service-name>
OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer <key>"
OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY
ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false
OTEL_EXPORTER_OTLP_ENDPOINTgets/v1/tracesappended. If you useOTEL_EXPORTER_OTLP_TRACES_ENDPOINTinstead, give the fullhttps://ingest.maple.dev/v1/traces.- The env vars must be in the process environment when
telemetry.pybuilds the exporter. Otherwise it silently targetslocalhost:4318with no key.
For Runner apps, create telemetry.py next to the entry point:
# telemetry.py
import json
from google.adk.plugins.base_plugin import BasePlugin
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
class SkipDuplicateToolSpans(BatchSpanProcessor):
"""Drops two ADK tool spans that would count a call twice: the
`execute_tool (merged)` summary of parallel calls, and the span of a call
paused for confirmation (it runs again, in its own span, once approved)."""
def on_end(self, span):
if span.name != "execute_tool (merged)" and not span.attributes.get("adk.awaiting_confirmation"):
super().on_end(span)
class ToolCallAttributes(BasePlugin):
"""Records each tool call's arguments and result on its `execute_tool` span,
and marks the span of a call that is waiting for confirmation."""
def __init__(self):
super().__init__(name="tool_call_attributes")
async def before_tool_callback(self, *, tool, tool_args, tool_context):
trace.get_current_span().set_attribute("gen_ai.tool.call.arguments", json.dumps(tool_args, default=str))
async def after_tool_callback(self, *, tool, tool_args, tool_context, result):
span = trace.get_current_span()
if tool_context.actions.requested_tool_confirmations:
span.set_attribute("adk.awaiting_confirmation", True)
span.set_attribute("gen_ai.tool.call.result", json.dumps(result, default=str))
# Reads OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
provider = TracerProvider(resource=Resource.create())
provider.add_span_processor(SkipDuplicateToolSpans(OTLPSpanExporter()))
trace.set_tracer_provider(provider)
- Import
telemetrybefore anygoogle.adkor agent import; if you use python-dotenv, callload_dotenv()on the line before it. - Existing provider found in Step 0: skip
TracerProvider(...)/set_tracer_provider; callexisting_provider.add_span_processor(SkipDuplicateToolSpans(OTLPSpanExporter()))right after it is created. KeepToolCallAttributes. - Register the plugin on every
Runner:Runner(..., plugins=[telemetry.ToolCallAttributes()]), appending to existing plugins. Foradk web/api_server, add it to theApp(name=..., root_agent=..., plugins=[...])in the agent module and do not create a provider (only the plugin is needed). The processor can't be added there without replacing ADK's provider, soexecute_tool (merged)spans and paused-confirmation spans stay; tell the user tool counts can be inflated under the CLI.
Step 3: Session id
- ADK writes
session.idasgen_ai.conversation.idoninvoke_agentandgenerate_contentspans; Maple groups on it. Onerun_async()= one trace = one turn. - Make sure every turn of a conversation calls
runner.run_async(user_id=..., session_id=<conversation id>, new_message=...)with the SAME id. Use the app's own chat/thread id.Runner(..., auto_create_session=True)creates the session under that id on the first turn.InMemoryRunner(ADK samples) has noauto_create_session: callawait runner.session_service.create_session(app_name=..., user_id=..., session_id=<conversation id>)once before the first turn;plugins=works the same. - Fix code that calls
session_service.create_session(...)withoutsession_idon every request: that mints a new UUID per turn (and the model loses history). - Never use a process-global constant session id for all users.
- Prefer
run_async(); in servers do not use syncrunner.run().
Step 4: Content
- The two OTel env vars from Step 2 put
gen_ai.input.messages,gen_ai.output.messages,gen_ai.system_instructions,gen_ai.tool.definitionsongenerate_contentspans as[{role, parts}]JSON. This is the only content Maple reads. OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=truemeans log records only. UseSPAN_ONLY.ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=falseremoves ADK'sgcp.vertex.agent.*duplicate blobs (Maple ignores them; they double payload size).- Per-request alternative:
RunConfig(telemetry=TelemetryConfig(genai_semconv_stability_opt_in="experimental", capture_message_content=ContentCapturingMode.SPAN_ONLY)), imports fromgoogle.adk.telemetry.context. - If the user wants no content: omit
OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENTand delete the twogen_ai.tool.call.*set_attributelines fromToolCallAttributes(keep the plugin: it still marks paused-confirmation spans). Tell the user the transcript will be empty.
Step 5: Tools, errors, sub-agents
- A tool call is marked failed (ERROR +
error.type) when the tool raises, or returns a dict with a non-empty"error"key (error.type=TOOL_ERROR). - Tools returning
{"status": "error", "error_message": ...}are NOT marked failed. If the repo uses that format, tell the user and offer to change it to{"error": ...}(changes what the model sees; confirm first). - A raised tool exception aborts the run. Only if the user wants the agent to continue, add:
class ToolErrorsAsResults(BasePlugin):
def __init__(self):
super().__init__(name="tool_errors_as_results")
async def on_tool_error_callback(self, *, tool, tool_args, tool_context, error):
return {"error": str(error)}
- Human approval (
FunctionTool(func, require_confirmation=True)): ADK opens anexecute_toolspan for the paused call and another when the approved call runs.ToolCallAttributesmarks the paused one andSkipDuplicateToolSpansdrops it, so each call counts once. Send the approval (FunctionResponsenamedadk_request_confirmation) with the samesession_id: it becomes its own trace in the same session. A rejected call is marked failed (This tool call is rejected.). - Every agent needs a distinct
name(becomesgen_ai.agent.name, Maple's lanes). - Agents as tools: use
sub_agents=[LlmAgent(..., mode="single_turn")]. Each call producesexecute_tool {agent}(counted as a tool call, result = the sub-agent's reply) and a siblinginvoke_agent {agent}under the parent agent, not nested; several delegations in one model response run in parallel. ReplaceAgentTool(agent=...)if present and the user agrees:AgentToolruns the sub-agent in a fresh in-memory session, so its spans carry a secondgen_ai.conversation.idand Maple may file the turn under the wrong session. SequentialAgent/ParallelAgent/LoopAgentneed no changes.
Step 6: Flush
- Scripts, CLIs, notebooks, jobs, tests: wrap the work in
try/finallyand calltelemetry.provider.force_flush()thentelemetry.provider.shutdown(). - Servers: call
provider.shutdown()in the shutdown hook (FastAPIlifespan). - CPU-throttled serverless (Cloud Run default, Cloud Functions, Lambda): call
provider.force_flush()before returning each response.
Step 7: Verify
Run one conversation of 2-3 turns with the same session id, one turn calling a tool, then flush. If the app has no scriptable entry point (adk web agent package, server, UI only), write a small driver for this run: one session id, 2+ turns, a tool call, flush before exit. With a real key, open Maple → Agent Sessions (https://app.maple.dev/agent-sessions, EU app.eu.maple.dev); data appears within about a minute. Check:
- Exactly one session for the conversation, framework Google ADK, one turn per
run_async(). A second conversation is a separate session. - Transcript shows user messages, assistant replies, tool calls with arguments and results.
- LLM calls and input/output tokens are non-zero on every turn, including streamed ones. Model =
gen_ai.request.model(e.g.openrouter/openai/gpt-4o-mini). - Tool calls carry real function names; no tool named
(merged tools); an approved confirmation-gated call counts once. - A failing tool is counted as an error; successful tools are not.
- Sub-agents appear as separate lanes with their own names.
- No duplicated model calls (no second instrumentor).
- Cost shows "unpriced" (expected: ADK records no cost).
With the Maple MCP: list_agent_sessions with search=<session id> returns one row.
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 span batch/ 401 lines on stderr. - A local console exporter shows the spans. Temporarily add
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))(from opentelemetry.sdk.trace.export import SimpleSpanProcessor, ConsoleSpanExporter), run one turn, and confirmgenerate_contentspans havegen_ai.conversation.id,gen_ai.input.messages,gen_ai.output.messages,gen_ai.usage.input_tokens;execute_toolspans havegen_ai.tool.call.arguments. Also useful with a real key when exports fail. Remove it afterwards.
Tell the user about the known gaps: cost is unpriced; the model is the requested id, not the served one, and there is no gen_ai.response.id.
Reference notes
-
Scope: ADK for Python here, ADK for TypeScript in references/typescript.md. ADK for Go and Kotlin emit the same span names; their provider setup is not covered.
-
Expected trace per turn:
invocation └─ invoke_agent assistant ├─ call_llm │ └─ generate_content openrouter/openai/gpt-4o-mini ├─ execute_tool get_weather └─ call_llm └─ generate_content openrouter/openai/gpt-4o-mini -
Tokens:
generate_contentcarriesgen_ai.usage.input_tokens/output_tokens, plusgen_ai.usage.cache_read.input_tokensandgen_ai.usage.reasoning.output_tokenswhen reported (cached inside input, thinking inside output).call_llmrepeats the same usage; Maple counts it ongenerate_contentonly, so totals and LLM call count are correct. Streamed turns (StreamingMode.SSE) report usage;LiteLlmrequests it viastream_options.include_usage. -
Cost: LiteLLM computes a cost but it never reaches ADK's spans; sessions are unpriced.
-
AgentTooldetail: Maple keeps onegen_ai.conversation.idper trace and picks the larger of the two, which is why the turn can move to another session. -
The approval
run_async()is its own turn, labeled with the original request. -
With the settings in this skill,
generate_contentspans carry no provider attribute; doesn't affect grouping, tokens or transcript. -
401 from the exporter (
ingest_unauthorized, "Invalid ingest key"): with a key you trust, it usually belongs to the other region (keys are region-bound): try the other endpoint.
Do not
- Do not drop or filter
call_llmspans:generate_contentwould lose its parent.