Frameworks
Google ADK
Trace Google Agent Development Kit agents in Python: each agent run, its model calls and its tool calls in one trace, with ADK's session id as the conversation.
With init() called before ADK is imported, every agent run is a span of its own, and each model call and tool call it makes nests underneath. There is nothing to add to the agent.
What you get
- A
run_agentspan for each run of an agent, named ingen_ai.agent.nameafter the agent'sname, with the user's message infunction.args, and the reply and the tool calls of the run infunction.result. The trace lists under that agent in Traces → Agents. - A
tool_callspan for each tool the agent runs, named after the tool, withgen_ai.tool.call.arguments,gen_ai.tool.call.resultandgen_ai.tool.description. The SDK adds them through ADK's before and after tool callbacks: its own runs first before the tool and last after it, so callbacks of your own keep working. - An
llm_callspan for each model call, from the integration of the client ADK calls the model with:litellm.acompletionwhen the agent's model is aLiteLlm, as in the tutorial. A Gemini model goes through the Google Gen AI client, which the SDK traces too; this guide did not run one. - ADK's own OpenTelemetry spans are left out, because they repeat what these spans record.
run_agent function_call gen_ai.agent.name = support_bot
litellm.acompletion llm_call the model asks for lookup_order
lookup_order tool_call arguments and result
litellm.acompletion llm_call the model answers
POST http_call the model client's request, at the top of the trace
POST http_callInstall
pip install syntropylabs-evalkit google-adk litellmSet up
import os
import evalkit
evalkit.init(subscription_key=os.environ["EVALKIT_SUBSCRIPTION_KEY"], service_name="adk-support")
from google.adk.agents import LlmAgent # import ADK after init()Where init() goes
- Before
google.adkis imported.init()patches ADK's agent class and the model clients, so call it at the top of the module that starts your app, then import ADK. - Create the runner after
init(). The patches sit on ADK's classes, so agents built later are covered without any change.
Tutorial: a support agent with one tool
- 1
Install and set the keys
Install the packages above and export the environment key and your model key. The agent reaches OpenAI through LiteLLM, which reads
OPENAI_API_KEY. - 2
Save the agent
Save
agent.pybelow. It runs one turn: the model asks forlookup_order, ADK runs the tool, and the model answers. - 3
Run it
Run
python agent.py. It prints the answer, and the SDK sends the spans when the script exits. - 4
Open the trace
In Traces, open the newest trace of the
adk-supportservice.
export EVALKIT_SUBSCRIPTION_KEY=tk_live_... # Settings → This project → Environments → Copy key
export OPENAI_API_KEY=sk-...import asyncio
import os
import evalkit
# init() runs before ADK is imported, so its agents, tool callbacks and model clients are patched.
evalkit.init(
subscription_key=os.environ["EVALKIT_SUBSCRIPTION_KEY"],
service_name="adk-support",
environment="development",
)
from google.adk.agents import LlmAgent # noqa: E402
from google.adk.models.lite_llm import LiteLlm # noqa: E402
from google.adk.runners import InMemoryRunner # noqa: E402
from google.genai import types # noqa: E402
def lookup_order(query: str) -> dict:
"""Find an order from the customer's words."""
return {"order_id": "1001", "status": "shipped", "arrives": "Thursday"}
agent = LlmAgent(
name="support_bot", # the run_agent span and the trace's agent take this name
model=LiteLlm(model="openai/gpt-4o-mini"), # through LiteLLM, which reads OPENAI_API_KEY
instruction="You are a support agent for an online shop. Use lookup_order for questions about an order.",
tools=[lookup_order],
)
runner = InMemoryRunner(agent=agent, app_name="support")
async def main() -> None:
session = await runner.session_service.create_session(app_name="support", user_id="u_42")
message = types.Content(role="user", parts=[types.Part(text="Where is my order 1001?")])
with evalkit.session(session.id, user_id="u_42"): # ADK's own session id groups the turns
async for event in runner.run_async(user_id="u_42", session_id=session.id, new_message=message):
if event.is_final_response() and event.content and event.content.parts:
print(event.content.parts[0].text)
asyncio.run(main())python agent.py
# Order 1001 shipped yesterday and arrives on Thursday.Tested with google-adk 2.12, LiteLLM 1.105 and Python 3.13.
See the trace
- The trace's root is
run_agent, and its agent issupport_bot. Clicklookup_orderto read the arguments the model chose and what the tool returned. - Traces → Sessions lists the conversation under ADK's session id, with user
u_42. - A second trace holds one
GETspan: LiteLLM downloads its model price list when it is imported. It belongs to no session.
Sessions, users and agents
- Open
evalkit.session(session.id, user_id=...)aroundrun_async, as the tutorial does, with ADK's own session id, so the turns of one ADK session are one conversation in Traces → Sessions. - The agent name comes from the agent's
name. A sub-agent run is arun_agentspan of its own, named after that sub-agent.
Pitfalls
- The model calls are missing. The client the model goes through was imported before
init(), or it is not one the SDK traces. Import ADK afterinit(); see What is traced automatically for the clients covered. - Log lines become spans; your log output stays as it was. Each record that propagates to the root logger is recorded as a
logspan, as a handler there would record it: at the level of the logger that wrote it, which is the root level,WARNING, unless that logger or your app sets another.init()changes no handler, formatter or level, so your console output stays as it was. Every record carriestrace_idandspan_id, so a format of your own can show them, as in%(asctime)s %(levelname)s [%(trace_id)s] %(name)s: %(message)s. On 0.3.4 and earlier,init()changes the format of the root logger's console handlers and sets the root level to INFO; configure logging afterinit()there. - TypeScript. The TypeScript SDK patches
@google/adktoo (itsRunnerand agents), but this guide covers Python only.
Safe by design
- Spans are queued in memory and sent from a background thread, so a request never waits for the trace service.
- If the trace service is down or unreachable, your app answers as before. The SDK retries each export 3 times, logs
evalkit: export failed after 3 retriesas a warning and drops those spans. - If the key is wrong, the trace service refuses the spans. After 3 refusals in a row the SDK logs one warning and stops exporting until the process restarts; your app keeps answering.
- A streamed model reply reaches your code chunk by chunk, unchanged. The SDK reads the same chunks on the side and writes the span when the stream ends.
- Tracing never breaks the call it records. An error inside the SDK is logged at debug level and the call runs as if untraced, and an exception from the provider reaches your code unchanged.
- A failed export never raises in your code, and
evalkit.flush()returns normally too. At exit the SDK sends what is queued within 3 seconds in all, whether or not the trace service answers, so an outage never holds up a deploy.
Next steps
- Choose what is recorded, from prompts to function arguments, and mask what must not leave the process: Configuration and privacy.
- Every library the SDK traces without code, and how streamed calls are counted: What is traced automatically.
- Follow one request across services, or run beside an OpenTelemetry setup you already have: OpenTelemetry and distributed tracing.
- Read the trace you just sent: Your first trace, and group turns into conversations: Sessions and users.