Skip to content

Commit c7558b5

Browse files
nirgaclaude
andcommitted
feat(agno): update span naming and add tool instrumentation
Changes: - Update span names to follow pattern: {name}.agent, {name}.team, {name}.tool - Add FunctionCall.execute() and FunctionCall.aexecute() instrumentation for tool calls - Switch from gen_ai.prompt/completion attributes to traceloop.entity.input/output - Add traceloop.entity.name for all span types - Create new _tool_wrappers.py module for tool instrumentation - Update tests to verify new span names and attributes Span hierarchy now properly captures: WeatherAssistant.agent [AGENT] └─ get_weather.tool [TOOL] 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3c1f5d7 commit c7558b5

File tree

4 files changed

+208
-37
lines changed

4 files changed

+208
-37
lines changed

packages/opentelemetry-instrumentation-agno/opentelemetry/instrumentation/agno/__init__.py

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@
55
from importlib.metadata import version as package_version, PackageNotFoundError
66

77
from opentelemetry import context as context_api
8+
from opentelemetry.instrumentation.agno._tool_wrappers import (
9+
_FunctionCallExecuteWrapper,
10+
_FunctionCallAExecuteWrapper,
11+
)
812
from opentelemetry.instrumentation.agno.config import Config
913
from opentelemetry.instrumentation.agno.utils import (
1014
dont_throw,
@@ -93,6 +97,22 @@ def _instrument(self, **kwargs):
9397
except Exception as e:
9498
logger.debug(f"Could not instrument Team: {e}")
9599

100+
# Wrap FunctionCall methods for tool execution
101+
try:
102+
wrap_function_wrapper(
103+
module="agno.tools",
104+
name="FunctionCall.execute",
105+
wrapper=_FunctionCallExecuteWrapper(tracer, duration_histogram, token_histogram),
106+
)
107+
108+
wrap_function_wrapper(
109+
module="agno.tools",
110+
name="FunctionCall.aexecute",
111+
wrapper=_FunctionCallAExecuteWrapper(tracer, duration_histogram, token_histogram),
112+
)
113+
except Exception as e:
114+
logger.debug(f"Could not instrument FunctionCall: {e}")
115+
96116
def _uninstrument(self, **kwargs):
97117
unwrap("agno.agent", "Agent.run")
98118
unwrap("agno.agent", "Agent.arun")
@@ -101,6 +121,11 @@ def _uninstrument(self, **kwargs):
101121
unwrap("agno.team", "Team.arun")
102122
except Exception:
103123
pass
124+
try:
125+
unwrap("agno.tools", "FunctionCall.execute")
126+
unwrap("agno.tools", "FunctionCall.aexecute")
127+
except Exception:
128+
pass
104129

105130

106131
class _AgentRunWrapper:
@@ -120,7 +145,7 @@ def __call__(self, wrapped, instance, args, kwargs):
120145
) or context_api.get_value("suppress_agno_instrumentation"):
121146
return wrapped(*args, **kwargs)
122147

123-
span_name = f"agno.agent.{getattr(instance, 'name', 'unknown')}"
148+
span_name = f"{getattr(instance, 'name', 'unknown')}.agent"
124149

125150
with self._tracer.start_as_current_span(
126151
span_name,
@@ -142,9 +167,7 @@ def __call__(self, wrapped, instance, args, kwargs):
142167

143168
if args and should_send_prompts():
144169
input_message = str(args[0])
145-
span.set_attribute(
146-
f"{GenAIAttributes.GEN_AI_PROMPT}.0.content",
147-
input_message)
170+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT, input_message)
148171

149172
import time
150173
start_time = time.time()
@@ -154,9 +177,8 @@ def __call__(self, wrapped, instance, args, kwargs):
154177
duration = time.time() - start_time
155178

156179
if hasattr(result, 'content') and should_send_prompts():
157-
span.set_attribute(
158-
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content",
159-
str(result.content))
180+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
181+
str(result.content))
160182

161183
if hasattr(result, 'run_id'):
162184
span.set_attribute("agno.run.id", result.run_id)
@@ -211,7 +233,7 @@ async def __call__(self, wrapped, instance, args, kwargs):
211233
) or context_api.get_value("suppress_agno_instrumentation"):
212234
return await wrapped(*args, **kwargs)
213235

214-
span_name = f"agno.agent.{getattr(instance, 'name', 'unknown')}"
236+
span_name = f"{getattr(instance, 'name', 'unknown')}.agent"
215237

216238
with self._tracer.start_as_current_span(
217239
span_name,
@@ -233,9 +255,7 @@ async def __call__(self, wrapped, instance, args, kwargs):
233255

234256
if args and should_send_prompts():
235257
input_message = str(args[0])
236-
span.set_attribute(
237-
f"{GenAIAttributes.GEN_AI_PROMPT}.0.content",
238-
input_message)
258+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT, input_message)
239259

240260
import time
241261
start_time = time.time()
@@ -245,9 +265,8 @@ async def __call__(self, wrapped, instance, args, kwargs):
245265
duration = time.time() - start_time
246266

247267
if hasattr(result, 'content') and should_send_prompts():
248-
span.set_attribute(
249-
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content",
250-
str(result.content))
268+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
269+
str(result.content))
251270

252271
if hasattr(result, 'run_id'):
253272
span.set_attribute("agno.run.id", result.run_id)
@@ -302,7 +321,7 @@ def __call__(self, wrapped, instance, args, kwargs):
302321
) or context_api.get_value("suppress_agno_instrumentation"):
303322
return wrapped(*args, **kwargs)
304323

305-
span_name = f"agno.team.{getattr(instance, 'name', 'unknown')}"
324+
span_name = f"{getattr(instance, 'name', 'unknown')}.team"
306325

307326
with self._tracer.start_as_current_span(
308327
span_name,
@@ -318,9 +337,7 @@ def __call__(self, wrapped, instance, args, kwargs):
318337

319338
if args and should_send_prompts():
320339
input_message = str(args[0])
321-
span.set_attribute(
322-
f"{GenAIAttributes.GEN_AI_PROMPT}.0.content",
323-
input_message)
340+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT, input_message)
324341

325342
import time
326343
start_time = time.time()
@@ -330,9 +347,8 @@ def __call__(self, wrapped, instance, args, kwargs):
330347
duration = time.time() - start_time
331348

332349
if hasattr(result, 'content') and should_send_prompts():
333-
span.set_attribute(
334-
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content",
335-
str(result.content))
350+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
351+
str(result.content))
336352

337353
if hasattr(result, 'run_id'):
338354
span.set_attribute("agno.run.id", result.run_id)
@@ -372,7 +388,7 @@ async def __call__(self, wrapped, instance, args, kwargs):
372388
) or context_api.get_value("suppress_agno_instrumentation"):
373389
return await wrapped(*args, **kwargs)
374390

375-
span_name = f"agno.team.{getattr(instance, 'name', 'unknown')}"
391+
span_name = f"{getattr(instance, 'name', 'unknown')}.team"
376392

377393
with self._tracer.start_as_current_span(
378394
span_name,
@@ -388,9 +404,7 @@ async def __call__(self, wrapped, instance, args, kwargs):
388404

389405
if args and should_send_prompts():
390406
input_message = str(args[0])
391-
span.set_attribute(
392-
f"{GenAIAttributes.GEN_AI_PROMPT}.0.content",
393-
input_message)
407+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT, input_message)
394408

395409
import time
396410
start_time = time.time()
@@ -400,9 +414,8 @@ async def __call__(self, wrapped, instance, args, kwargs):
400414
duration = time.time() - start_time
401415

402416
if hasattr(result, 'content') and should_send_prompts():
403-
span.set_attribute(
404-
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content",
405-
str(result.content))
417+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT,
418+
str(result.content))
406419

407420
if hasattr(result, 'run_id'):
408421
span.set_attribute("agno.run.id", result.run_id)
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
"""Wrapper classes for FunctionCall tool execution instrumentation."""
2+
3+
import json
4+
import time
5+
6+
from opentelemetry import context as context_api
7+
from opentelemetry.instrumentation.agno.utils import dont_throw, should_send_prompts
8+
from opentelemetry.semconv._incubating.attributes import gen_ai_attributes as GenAIAttributes
9+
from opentelemetry.semconv_ai import SpanAttributes, TraceloopSpanKindValues
10+
from opentelemetry.trace import SpanKind
11+
from opentelemetry.trace.status import Status, StatusCode
12+
13+
14+
class _FunctionCallExecuteWrapper:
15+
"""Wrapper for FunctionCall.execute() method to capture synchronous tool execution."""
16+
17+
def __init__(self, tracer, duration_histogram, token_histogram):
18+
"""Initialize the wrapper with OpenTelemetry instrumentation objects."""
19+
self._tracer = tracer
20+
self._duration_histogram = duration_histogram
21+
self._token_histogram = token_histogram
22+
23+
@dont_throw
24+
def __call__(self, wrapped, instance, args, kwargs):
25+
"""Wrap the FunctionCall.execute() call with tracing instrumentation."""
26+
if context_api.get_value(
27+
context_api._SUPPRESS_INSTRUMENTATION_KEY
28+
) or context_api.get_value("suppress_agno_instrumentation"):
29+
return wrapped(*args, **kwargs)
30+
31+
function_name = getattr(instance.function, 'name', 'unknown')
32+
span_name = f"{function_name}.tool"
33+
34+
with self._tracer.start_as_current_span(
35+
span_name,
36+
kind=SpanKind.CLIENT,
37+
) as span:
38+
try:
39+
span.set_attribute(GenAIAttributes.GEN_AI_SYSTEM, "agno")
40+
span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND,
41+
TraceloopSpanKindValues.TOOL.value)
42+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, function_name)
43+
44+
if hasattr(instance.function, 'description') and instance.function.description:
45+
span.set_attribute("tool.description", instance.function.description)
46+
47+
# Capture input arguments
48+
if should_send_prompts():
49+
if hasattr(instance, 'arguments') and instance.arguments:
50+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT,
51+
json.dumps(instance.arguments))
52+
elif kwargs:
53+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT,
54+
json.dumps(kwargs))
55+
56+
start_time = time.time()
57+
58+
result = wrapped(*args, **kwargs)
59+
60+
duration = time.time() - start_time
61+
62+
if result is not None and should_send_prompts():
63+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, str(result))
64+
65+
span.set_status(Status(StatusCode.OK))
66+
67+
self._duration_histogram.record(
68+
duration,
69+
attributes={
70+
GenAIAttributes.GEN_AI_SYSTEM: "agno",
71+
SpanAttributes.TRACELOOP_SPAN_KIND: TraceloopSpanKindValues.TOOL.value,
72+
}
73+
)
74+
75+
return result
76+
77+
except Exception as e:
78+
span.set_status(Status(StatusCode.ERROR, str(e)))
79+
span.record_exception(e)
80+
raise
81+
82+
83+
class _FunctionCallAExecuteWrapper:
84+
"""Wrapper for FunctionCall.aexecute() method to capture asynchronous tool execution."""
85+
86+
def __init__(self, tracer, duration_histogram, token_histogram):
87+
"""Initialize the wrapper with OpenTelemetry instrumentation objects."""
88+
self._tracer = tracer
89+
self._duration_histogram = duration_histogram
90+
self._token_histogram = token_histogram
91+
92+
@dont_throw
93+
async def __call__(self, wrapped, instance, args, kwargs):
94+
"""Wrap the FunctionCall.aexecute() call with tracing instrumentation."""
95+
if context_api.get_value(
96+
context_api._SUPPRESS_INSTRUMENTATION_KEY
97+
) or context_api.get_value("suppress_agno_instrumentation"):
98+
return await wrapped(*args, **kwargs)
99+
100+
function_name = getattr(instance.function, 'name', 'unknown')
101+
span_name = f"{function_name}.tool"
102+
103+
with self._tracer.start_as_current_span(
104+
span_name,
105+
kind=SpanKind.CLIENT,
106+
) as span:
107+
try:
108+
span.set_attribute(GenAIAttributes.GEN_AI_SYSTEM, "agno")
109+
span.set_attribute(SpanAttributes.TRACELOOP_SPAN_KIND,
110+
TraceloopSpanKindValues.TOOL.value)
111+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_NAME, function_name)
112+
113+
if hasattr(instance.function, 'description') and instance.function.description:
114+
span.set_attribute("tool.description", instance.function.description)
115+
116+
# Capture input arguments
117+
if should_send_prompts():
118+
if hasattr(instance, 'arguments') and instance.arguments:
119+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT,
120+
json.dumps(instance.arguments))
121+
elif kwargs:
122+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_INPUT,
123+
json.dumps(kwargs))
124+
125+
start_time = time.time()
126+
127+
result = await wrapped(*args, **kwargs)
128+
129+
duration = time.time() - start_time
130+
131+
if result is not None and should_send_prompts():
132+
span.set_attribute(SpanAttributes.TRACELOOP_ENTITY_OUTPUT, str(result))
133+
134+
span.set_status(Status(StatusCode.OK))
135+
136+
self._duration_histogram.record(
137+
duration,
138+
attributes={
139+
GenAIAttributes.GEN_AI_SYSTEM: "agno",
140+
SpanAttributes.TRACELOOP_SPAN_KIND: TraceloopSpanKindValues.TOOL.value,
141+
}
142+
)
143+
144+
return result
145+
146+
except Exception as e:
147+
span.set_status(Status(StatusCode.ERROR, str(e)))
148+
span.record_exception(e)
149+
raise

packages/opentelemetry-instrumentation-agno/tests/test_agent.py

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from opentelemetry.semconv._incubating.attributes import (
55
gen_ai_attributes as GenAIAttributes,
66
)
7+
from opentelemetry.semconv_ai import SpanAttributes
78

89

910
@pytest.mark.vcr
@@ -21,17 +22,15 @@ def test_agent_run_basic(instrument, span_exporter, reader):
2122
assert len(spans) >= 1
2223

2324
agent_span = spans[-1]
24-
assert agent_span.name == "agno.agent.TestAgent"
25+
assert agent_span.name == "TestAgent.agent"
2526
assert agent_span.attributes.get(GenAIAttributes.GEN_AI_SYSTEM) == "agno"
2627
assert agent_span.attributes.get(GenAIAttributes.GEN_AI_AGENT_NAME) == "TestAgent"
2728
assert agent_span.attributes.get(GenAIAttributes.GEN_AI_REQUEST_MODEL) == "gpt-4o-mini"
2829

29-
prompt_content = agent_span.attributes.get(f"{GenAIAttributes.GEN_AI_PROMPT}.0.content")
30+
prompt_content = agent_span.attributes.get(SpanAttributes.TRACELOOP_ENTITY_INPUT)
3031
assert prompt_content == "What is 2 + 2?"
3132

32-
completion_content = agent_span.attributes.get(
33-
f"{GenAIAttributes.GEN_AI_COMPLETION}.0.content"
34-
)
33+
completion_content = agent_span.attributes.get(SpanAttributes.TRACELOOP_ENTITY_OUTPUT)
3534
assert completion_content is not None
3635

3736

@@ -51,11 +50,11 @@ async def test_agent_arun_basic(instrument, span_exporter, reader):
5150
assert len(spans) >= 1
5251

5352
agent_span = spans[-1]
54-
assert agent_span.name == "agno.agent.AsyncTestAgent"
53+
assert agent_span.name == "AsyncTestAgent.agent"
5554
assert agent_span.attributes.get(GenAIAttributes.GEN_AI_SYSTEM) == "agno"
5655
assert agent_span.attributes.get(GenAIAttributes.GEN_AI_AGENT_NAME) == "AsyncTestAgent"
5756

58-
prompt_content = agent_span.attributes.get(f"{GenAIAttributes.GEN_AI_PROMPT}.0.content")
57+
prompt_content = agent_span.attributes.get(SpanAttributes.TRACELOOP_ENTITY_INPUT)
5958
assert "capital of France" in prompt_content
6059

6160

@@ -78,10 +77,19 @@ def add_numbers(a: int, b: int) -> int:
7877
spans = span_exporter.get_finished_spans()
7978
assert len(spans) >= 1
8079

80+
# Check for agent span
8181
agent_span = spans[-1]
82-
assert agent_span.name == "agno.agent.ToolAgent"
82+
assert agent_span.name == "ToolAgent.agent"
8383
assert agent_span.attributes.get(GenAIAttributes.GEN_AI_SYSTEM) == "agno"
8484

85+
# Check for tool spans (if tools were executed)
86+
tool_spans = [s for s in spans if s.name == "add_numbers.tool"]
87+
# Tool spans may or may not be present depending on whether the agent actually calls the tool
88+
# so we just check that if they exist, they have the right attributes
89+
for tool_span in tool_spans:
90+
assert tool_span.attributes.get(SpanAttributes.TRACELOOP_ENTITY_NAME) == "add_numbers"
91+
assert tool_span.attributes.get(GenAIAttributes.GEN_AI_SYSTEM) == "agno"
92+
8593

8694
@pytest.mark.vcr
8795
def test_agent_metrics(instrument, span_exporter, reader):

0 commit comments

Comments
 (0)