Frameworks
Strands Agents
Trace Strands Agents through its OpenTelemetry spans: init() installs the tracer provider Strands records with, and its agent, model and tool spans are typed by the GenAI conventions, with a tested tutorial.
Strands Agents records its agent loop with OpenTelemetry, on the GenAI semantic conventions. EvalKit has no Strands patch of its own: init() installs the OpenTelemetry tracer provider Strands records with, and the SDK types each Strands span by its gen_ai.operation.name. This page builds a support agent with two tools in Python, runs it once, and reads the trace.
What you get
invoke_agentis anagentspan named after the agent, such asinvoke_agent support-bot, with the user's message as its prompt and the agent's reply as its completion. Its usage totals the model calls under it, so it carries no tokens of its own.execute_toolis atool_callspan named after the tool, withtool.argumentsandtool.result. A tool whosegen_ai.tool.statusiserroris an error span.execute_event_loop_cycleis aninternalspan: one per turn of the agent loop, never a tool or a model call.- The model call Strands records,
chat, is aninternalspan here, because EvalKit records the same request itself, through the OpenAI client, as thechat.completions.createllm_callinside it. The call is counted once. - The OpenAI client's HTTP requests show as
POSTspans. Strands' OpenAI model asks for usage in its streams, so the streamed model calls carry token counts.
invoke_agent support-bot agent
execute_event_loop_cycle internal
chat internal Strands' record of the model call
chat.completions.create llm_call tokens 65 in, 34 out
POST http_call
lookup_order tool_call
execute_event_loop_cycle internal
chat internal
chat.completions.create llm_call
POST http_call
search_policy tool_call
execute_event_loop_cycle internal
chat internal
chat.completions.create llm_call
POST http_callInstall
pip install syntropylabs-evalkit "strands-agents[openai]"Set up
import os
import evalkit
evalkit.init(subscription_key=os.environ["EVALKIT_SUBSCRIPTION_KEY"], service_name="my-agent")
# Import Strands after init(), so the tracer provider it records with is the one init() installed.
from strands import AgentWhere init() goes
- Call
init()once, before Strands is imported. With no tracer provider installed yet,init()installs one and sets it as OpenTelemetry's global provider, which Strands records with. - If you set up OpenTelemetry first, with a provider of the OpenTelemetry SDK,
init()adds EvalKit's exporter to that provider instead, and your own exporters keep their spans. See OpenTelemetry and distributed tracing. - Strands can also send its spans without the SDK, with its own OTLP exporter pointed at the trace service's OTLP receiver. Use one path, not both, so each span arrives once.
Tutorial: a support agent with two tools
The agent looks an order up and checks the refund policy before it answers, so one question makes three model calls and two tool calls. Its question and tools differ from the other framework pages because the test run used a model that streams its tool calls, as Strands reads them.
- 1
Install the packages
Run the install line above.
- 2
Set the keys
Export the environment key of your Environment and your OpenAI key.
- 3
Save the code
Save it as
agent.py. - 4
Run it
python agent.pyprints the agent's reply. The SDK sends the queued spans when the script exits. - 5
Open the trace
In Traces, open the newest trace of the service
strands-support.
export EVALKIT_SUBSCRIPTION_KEY=tk_live_... # Settings → This project → Environments → Copy key
export OPENAI_API_KEY=sk-...import os
import evalkit
# init() runs before Strands is imported: it installs the OpenTelemetry tracer provider that
# Strands records its agent, model and tool spans with, and sends those spans to EvalKit.
evalkit.init(
subscription_key=os.environ["EVALKIT_SUBSCRIPTION_KEY"],
service_name="strands-support",
environment="development",
)
from strands import Agent, tool # noqa: E402
from strands.models.openai import OpenAIModel # noqa: E402
@tool
def lookup_order(order_id: str) -> dict:
"""Look up an order by its id."""
return {"order_id": order_id, "item": "Vortex blender", "price": 89.99, "delivered": "2 October"}
@tool
def search_policy(query: str) -> str:
"""Search the refund policy."""
return "Items damaged on arrival are refundable within 30 days."
agent = Agent(
name="support-bot", # the agent span is named after it, and the trace lists under it
model=OpenAIModel(model_id="gpt-4o-mini"), # reads OPENAI_API_KEY
tools=[lookup_order, search_policy],
system_prompt="You are a support agent for an online shop. Look orders up and check the policy before you answer.",
callback_handler=None, # no console streaming; the spans carry the reply
)
with evalkit.session("conv_strands_1", user_id="u_42"): # groups the turns of one conversation
result = agent("My blender from order 48213 arrived cracked. I want my money back.")
print(result)python agent.pyIt prints the agent's reply, for example:
Order 48213 (Vortex blender, $89.99) was delivered on 2 October. Items damaged on arrival are refundable within 30 days, so you qualify. Shall I refund $89.99 to the card you paid with?Tested with syntropylabs-evalkit 0.3.4, strands-agents 1.60 and openai 2.54: one trace of 15 spans.
See the trace
- The waterfall shows the tree under What you get.
- Click
invoke_agent support-botto read the question and the reply, achat.completions.createspan for the messages and tokens, andlookup_orderorsearch_policyfor the tool's arguments and result. - Traces → Sessions lists
conv_strands_1, and Traces → Agents listssupport-bot.
Sessions, users and agents
- Run the agent inside
with evalkit.session(session_id, user_id=...), as the tutorial does. Every Strands span started inside the block carries the session and user, because the SDK records the scope in force when each span starts. - The agent's
namenames theinvoke_agentspan and setsgen_ai.agent.nameon it, and a trace's agent is its root span's name, so the trace lists undersupport-botwith noset_agentcall. - EvalKit also reads the ids from span attributes:
session.id,conversation.idorgen_ai.conversation.idfor the session, anduser.idorenduser.idfor the user.
Pitfalls
- The
chatspans have no tokens. They are Strands' records of requests the SDK recorded itself aschat.completions.create, so they are kept asinternalspans and each call is counted once. SetEVALKIT_COUNT_FOREIGN_LLM=1only when Strands is the one recording its model calls, so both copies are counted. - Two exports of the same spans. If you also point Strands' OTLP exporter at the trace service, each span arrives twice. Use
init()or the exporter, not both. - 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.
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.