Trace Google ADK agents with OpenTelemetry
Send Google Agent Development Kit (ADK) traces to Maple with the transcript, tool calls and tokens, one session per ADK session.
Google’s Agent Development Kit (ADK) emits OpenTelemetry spans on its own, in Python (google-adk) and TypeScript (@google/adk), so you need no instrumentation package. You register a tracer provider and add a few lines that put the transcript and tool calls in the format Maple reads.
Quick setup with a coding agent
Copy this prompt into a coding agent that can run shell commands, such as Claude Code, Codex or Cursor. It installs the maple-agent-tracing-google-adk skill and follows it.
Set up Maple agent tracing for Google ADK in this project.
Install the skill with `npx skills add MapleTechLabs/maple/skills --skill maple-agent-tracing-google-adk -y`, then follow it.
My Maple ingest key is maple_pk_... and my organization is in the US region.
Your ingest key is in Settings → Ingestion. If your organization is in the EU region, change US to EU in the prompt.
Install ADK and the OTLP exporter
pip install "google-adk>=2.10" litellm opentelemetry-exporter-otlp-proto-httpuv add "google-adk>=2.10" litellm opentelemetry-exporter-otlp-proto-httplitellm is only needed for non-Gemini models. Don’t pin a newer OpenTelemetry version: ADK 2.10 caps opentelemetry-sdk at 1.42.1.
npm install @google/adk@^2.1 zod @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-protopnpm add @google/adk@^2.1 zod @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-protobun add @google/adk@^2.1 zod @opentelemetry/sdk-node @opentelemetry/exporter-trace-otlp-protozod is for the tool in the example below.
Configure the export
export OTEL_SERVICE_NAME=support-agent
export OTEL_EXPORTER_OTLP_ENDPOINT=https://ingest.maple.dev
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"
# Put prompts, replies and tool calls on span attributes, in the format Maple reads
export OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental
export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLY
# Drop ADK's own copies of the same content, which Maple doesn't read
export ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=falseEU organizations use https://ingest.eu.maple.dev. Use SPAN_ONLY exactly, because true leaves the transcript empty.
These settings store every prompt and tool result in Maple. To keep structure and tokens without content, leave OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT unset and delete the two gen_ai.tool.call.* lines from the plugin below.
export OTEL_SERVICE_NAME="support-agent"
export OTEL_EXPORTER_OTLP_ENDPOINT="https://ingest.maple.dev"
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer YOUR_INGEST_KEY"EU organizations use https://ingest.eu.maple.dev. The exporter appends /v1/traces itself.
These settings store every prompt and tool result in Maple. To keep structure and tokens without content, set ADK_CAPTURE_MESSAGE_CONTENT_IN_SPANS=false.
Register a tracer provider
A Runner in your own app, worker or script exports nothing until you register a tracer provider. Add a telemetry.py:
# 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 telemetry as the first line of your entry point and register the plugin on the runner:
# main.py
import telemetry # first, so the provider exists before ADK runs
from google.adk.agents import LlmAgent
from google.adk.models.lite_llm import LiteLlm
from google.adk.runners import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types
def get_weather(city: str) -> dict:
"""Get the current weather for a city."""
return {"city": city, "temperature_c": 21, "condition": "partly cloudy"}
agent = LlmAgent(
name="assistant",
model=LiteLlm(model="openrouter/openai/gpt-4o-mini"),
instruction="You are a concise assistant.",
tools=[get_weather],
)
runner = Runner(
app_name="support",
agent=agent,
session_service=InMemorySessionService(),
plugins=[telemetry.ToolCallAttributes()],
auto_create_session=True,
)If your app already sets up OpenTelemetry (Logfire, Sentry, a platform agent), add the SkipDuplicateToolSpans(OTLPSpanExporter()) processor to that provider instead.
adk web and adk api_server create the provider themselves. There, skip it and register the plugin on your App: App(name="support", root_agent=agent, plugins=[ToolCallAttributes()]).
ADK records each model request, reply and tool call on its spans in its own format. The span processor below rewrites them into the attributes Maple reads. Create an instrumentation.ts and import it as the first line of your entry point (import "./instrumentation"):
// instrumentation.ts
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto"
import { NodeSDK, tracing } from "@opentelemetry/sdk-node"
type Part = {
text?: string
thought?: boolean
functionCall?: { id?: string; name?: string; args?: unknown }
functionResponse?: { id?: string; response?: unknown }
}
type Content = { role?: string; parts?: Part[] }
const parse = (value: unknown) => (typeof value === "string" ? JSON.parse(value) : {})
// Gemini content -> the GenAI message format Maple reads
const toParts = (parts: Part[] = []) =>
parts.flatMap((part): object[] => {
if (part.functionCall) {
const { id, name, args } = part.functionCall
return [{ type: "tool_call", id, name, arguments: args }]
}
if (part.functionResponse) {
const { id, response } = part.functionResponse
return [{ type: "tool_call_response", id, response }]
}
if (typeof part.text !== "string") return []
return [{ type: part.thought ? "reasoning" : "text", content: part.text }]
})
const toMessage = ({ role, parts }: Content) => ({
role: parts?.some((part) => part.functionResponse) ? "tool" : role === "model" ? "assistant" : "user",
parts: toParts(parts),
})
/** Copies what ADK records on its spans into the GenAI attributes Maple reads. */
class AdkSpanProcessor extends tracing.BatchSpanProcessor {
override onEnd(span: tracing.ReadableSpan) {
// ADK's summary of parallel tool calls, which are also traced one by one
if (span.name === "execute_tool (merged)") return
const attributes = span.attributes
if (span.name === "call_llm") {
const request = parse(attributes["gcp.vertex.agent.llm_request"])
const response = parse(attributes["gcp.vertex.agent.llm_response"])
const usage = response.usageMetadata ?? {}
const system = request.config?.systemInstruction
attributes["gen_ai.operation.name"] = "chat"
attributes["gen_ai.provider.name"] = "gcp.gemini"
if (usage.thoughtsTokenCount) {
attributes["gen_ai.usage.reasoning.output_tokens"] = usage.thoughtsTokenCount
}
if (usage.cachedContentTokenCount) attributes["gen_ai.usage.cache_read.input_tokens"] = usage.cachedContentTokenCount
if (request.contents) attributes["gen_ai.input.messages"] = JSON.stringify(request.contents.map(toMessage))
if (response.content?.parts?.length) {
const finish_reason = response.finishReason?.toLowerCase()
attributes["gen_ai.output.messages"] = JSON.stringify([{ ...toMessage(response.content), finish_reason }])
}
if (system) {
const parts = typeof system === "string" ? [{ type: "text", content: system }] : toParts(system.parts)
attributes["gen_ai.system_instructions"] = JSON.stringify(parts)
}
} else if (span.name.startsWith("execute_tool ")) {
if (attributes["gen_ai.operation.name"] === undefined) {
// ADK leaves the span bare when the tool threw or doesn't exist
attributes["gen_ai.operation.name"] = "execute_tool"
attributes["gen_ai.tool.name"] = span.name.slice("execute_tool ".length)
attributes["error.type"] = "tool_error"
} else {
const result = attributes["gcp.vertex.agent.tool_response"]
attributes["gen_ai.tool.call.arguments"] = attributes["gcp.vertex.agent.tool_call_args"]
attributes["gen_ai.tool.call.result"] = result
if (parse(result).error) attributes["error.type"] = "tool_error"
}
}
// ADK's own copies of the same content, which Maple doesn't read
for (const key of ["llm_request", "llm_response", "tool_call_args", "tool_response"]) {
delete attributes[`gcp.vertex.agent.${key}`]
}
super.onEnd(span)
}
}
// Reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS
export const spanProcessor = new AdkSpanProcessor(new OTLPTraceExporter())
// Reads OTEL_SERVICE_NAME
export const sdk = new NodeSDK({ spanProcessors: [spanProcessor] })
sdk.start()If your app already starts OpenTelemetry (Sentry, auto-instrumentation, your own NodeTracerProvider), skip the NodeSDK lines and add spanProcessor to that provider’s span processors instead.
npx adk web and npx adk api_server create their own provider without this processor, so sessions traced there show no transcript. Trace your own Runner instead.
Use one ADK session per conversation
Each runner.run_async() call is one turn. Pass your conversation id as session_id on every turn:
async def chat(conversation_id: str, user_id: str, text: str) -> str:
reply = ""
async for event in runner.run_async(
user_id=user_id,
session_id=conversation_id, # the same id on every turn of this conversation
new_message=types.Content(role="user", parts=[types.Part(text=text)]),
):
if event.is_final_response() and event.content and event.content.parts:
reply = "".join(part.text or "" for part in event.content.parts)
return replyWith auto_create_session=True, the runner creates the session on the first turn. Don’t call create_session() without a session_id, or every message becomes its own session.
To call an agent like a tool, add it to sub_agents with mode="single_turn" instead of using AgentTool, which can move the turn into a separate session.
Each runner.runAsync() call is one turn. Pass your conversation id as sessionId on every turn:
// agent.ts
import { FunctionTool, InMemorySessionService, LlmAgent, Runner } from "@google/adk"
import { z } from "zod"
const getWeather = new FunctionTool({
name: "get_weather",
description: "Get the current weather for a city.",
parameters: z.object({ city: z.string() }),
execute: async ({ city }) => ({ city, temperature_c: 21, condition: "partly cloudy" }),
})
const agent = new LlmAgent({
name: "assistant",
model: "gemini-2.5-flash",
instruction: "You are a concise assistant.",
tools: [getWeather],
})
const sessionService = new InMemorySessionService()
const runner = new Runner({ appName: "support", agent, sessionService })
export async function chat(conversationId: string, userId: string, text: string) {
// The same id on every turn of this conversation
await sessionService.getOrCreateSession({ appName: "support", userId, sessionId: conversationId })
let reply = ""
for await (const event of runner.runAsync({
userId,
sessionId: conversationId,
newMessage: { role: "user", parts: [{ text }] },
})) {
if (event.content?.parts && !event.partial) {
reply = event.content.parts.map((part) => part.text ?? "").join("")
}
}
return reply
}getOrCreateSession() creates the session under your id on the first turn. Don’t call createSession() without a sessionId, or every message becomes its own session.
Flush before a short-lived process exits
A script, notebook cell or job that exits within 5 seconds of its last turn loses that turn. Flush when the work ends:
# script.py
import telemetry # first
import asyncio
from main import chat
async def main():
try:
await chat("support-4821", "user-17", "What's the weather in Berlin?")
finally:
telemetry.provider.force_flush()
telemetry.provider.shutdown()
asyncio.run(main())In a server, call provider.shutdown() from your shutdown hook. On Cloud Run or another platform that freezes the CPU between requests, call provider.force_flush() before each response returns.
A script or job that exits right after its last turn loses that turn. Shut the SDK down when the work ends:
// script.ts
import { sdk } from "./instrumentation"
import { chat } from "./agent"
try {
console.log(await chat("support-4821", "user-17", "What's the weather in Berlin?"))
} finally {
await sdk.shutdown()
}In a server, call await sdk.shutdown() from your shutdown hook. In a serverless handler, or on a platform that freezes the CPU between requests, call await spanProcessor.forceFlush() before each response returns.
Check that it works
Run a conversation of two or three turns, one calling a tool, then open Agent Sessions. You should see one session with the framework Google ADK, one turn per run, tool calls with their arguments and results, and tokens on each model call. Cost shows as unpriced.
Troubleshooting
- No spans at all. No tracer provider is registered when the runner runs. Import
telemetry.pyorinstrumentation.tsfirst. - Tokens but an empty transcript. In Python,
OTEL_SEMCONV_STABILITY_OPT_INorOTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=SPAN_ONLYis missing, or the capture variable istrue. In TypeScript,AdkSpanProcessorisn’t on the provider, orADK_CAPTURE_MESSAGE_CONTENT_IN_SPANSisfalse. - Every message is its own session. Pass the same conversation id as the session id on every turn.
- A failing tool shows as successful. It returned
{"status": "error", ...}. Return an object with an"error"key, or raise an error. - Every model call appears twice. Another instrumentor wraps the same calls (
litellm.callbacks = ["otel"],openinference-instrumentation-google-adk), or a second OpenTelemetry SDK exports the same spans. Remove it.
Related
- Agent Sessions overview: how Maple builds sessions, turns and checks.
- ADK agent activity traces: ADK’s span reference and export setup.