Skip to content

Commit a92d031

Browse files
authored
fix(agno): propagate run identity to child spans (#239)
2 parents 7567eac + 6097233 commit a92d031

4 files changed

Lines changed: 568 additions & 38 deletions

File tree

instrumentation-loongsuite/loongsuite-instrumentation-agno/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## Unreleased
99

10+
### Changed
11+
12+
- Propagate Agno run user and session identity to model and tool spans while
13+
preferring ENTRY baggage identity over Agno framework-provided values.
14+
1015
### Added
1116

1217
- Capture `gen_ai.skill.*` attributes on Agno skill tool spans such as

instrumentation-loongsuite/loongsuite-instrumentation-agno/src/opentelemetry/instrumentation/agno/_wrapper.py

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@
2828
create_agent_invocation,
2929
create_llm_invocation,
3030
create_tool_invocation,
31+
reset_current_agno_run_identity,
32+
set_current_agno_run_identity,
3133
update_agent_invocation_from_events,
3234
update_agent_invocation_from_response,
3335
update_llm_invocation_from_response,
@@ -113,6 +115,7 @@ def _run(
113115
self._handler.start_invoke_agent(
114116
invocation, context=otel_context.get_current()
115117
)
118+
identity_token = set_current_agno_run_identity(invocation.attributes)
116119
try:
117120
response = wrapped(*args, **kwargs)
118121
update_agent_invocation_from_response(invocation, response)
@@ -125,6 +128,8 @@ def _run(
125128
_error(exc),
126129
)
127130
raise
131+
finally:
132+
reset_current_agno_run_identity(identity_token)
128133

129134
def _run_stream(
130135
self,
@@ -144,6 +149,21 @@ def generator() -> Iterator[Any]:
144149
self._handler.start_invoke_agent(
145150
invocation, context=parent_context
146151
)
152+
identity_token = None
153+
154+
def enter_identity() -> None:
155+
nonlocal identity_token
156+
identity_token = set_current_agno_run_identity(
157+
invocation.attributes
158+
)
159+
160+
def exit_identity() -> None:
161+
nonlocal identity_token
162+
if identity_token is not None:
163+
reset_current_agno_run_identity(identity_token)
164+
identity_token = None
165+
166+
enter_identity()
147167
try:
148168
stream = wrapped(*args, **kwargs)
149169
for event in stream:
@@ -152,26 +172,37 @@ def generator() -> Iterator[Any]:
152172
timeit.default_timer()
153173
)
154174
events.append(event)
155-
yield event
175+
# Do not expose run-local identity to caller code while
176+
# the stream chunk is yielded outside this wrapper.
177+
exit_identity()
178+
try:
179+
yield event
180+
finally:
181+
enter_identity()
156182
update_agent_invocation_from_events(invocation, events)
157183
_finish_invocation(self._handler.stop_invoke_agent, invocation)
158184
finalized = True
159185
except BaseException as exc:
160186
error = exc
161187
raise
162188
finally:
163-
if not finalized:
164-
if error is None or _is_stream_close(error):
165-
update_agent_invocation_from_events(invocation, events)
166-
_finish_invocation(
167-
self._handler.stop_invoke_agent, invocation
168-
)
169-
else:
170-
_finish_invocation(
171-
self._handler.fail_invoke_agent,
172-
invocation,
173-
_error(error),
174-
)
189+
try:
190+
if not finalized:
191+
if error is None or _is_stream_close(error):
192+
update_agent_invocation_from_events(
193+
invocation, events
194+
)
195+
_finish_invocation(
196+
self._handler.stop_invoke_agent, invocation
197+
)
198+
else:
199+
_finish_invocation(
200+
self._handler.fail_invoke_agent,
201+
invocation,
202+
_error(error),
203+
)
204+
finally:
205+
exit_identity()
175206

176207
return generator()
177208

@@ -214,6 +245,7 @@ async def _arun(
214245
) -> Any:
215246
invocation = create_agent_invocation(instance, arguments)
216247
self._handler.start_invoke_agent(invocation, context=parent_context)
248+
identity_token = set_current_agno_run_identity(invocation.attributes)
217249
try:
218250
response = wrapped(*args, **kwargs)
219251
if inspect.isawaitable(response):
@@ -228,6 +260,8 @@ async def _arun(
228260
_error(exc),
229261
)
230262
raise
263+
finally:
264+
reset_current_agno_run_identity(identity_token)
231265

232266
async def _arun_stream(
233267
self,
@@ -244,6 +278,21 @@ async def _arun_stream(
244278
finalized = False
245279
error = None
246280
self._handler.start_invoke_agent(invocation, context=parent_context)
281+
identity_token = None
282+
283+
def enter_identity() -> None:
284+
nonlocal identity_token
285+
identity_token = set_current_agno_run_identity(
286+
invocation.attributes
287+
)
288+
289+
def exit_identity() -> None:
290+
nonlocal identity_token
291+
if identity_token is not None:
292+
reset_current_agno_run_identity(identity_token)
293+
identity_token = None
294+
295+
enter_identity()
247296
try:
248297
stream = wrapped(*args, **kwargs)
249298
if inspect.isawaitable(stream):
@@ -252,26 +301,35 @@ async def _arun_stream(
252301
if invocation.monotonic_first_token_s is None:
253302
invocation.monotonic_first_token_s = timeit.default_timer()
254303
events.append(event)
255-
yield event
304+
# Do not expose run-local identity to caller code while
305+
# the stream chunk is yielded outside this wrapper.
306+
exit_identity()
307+
try:
308+
yield event
309+
finally:
310+
enter_identity()
256311
update_agent_invocation_from_events(invocation, events)
257312
_finish_invocation(self._handler.stop_invoke_agent, invocation)
258313
finalized = True
259314
except BaseException as exc:
260315
error = exc
261316
raise
262317
finally:
263-
if not finalized:
264-
if error is None or _is_stream_close(error):
265-
update_agent_invocation_from_events(invocation, events)
266-
_finish_invocation(
267-
self._handler.stop_invoke_agent, invocation
268-
)
269-
else:
270-
_finish_invocation(
271-
self._handler.fail_invoke_agent,
272-
invocation,
273-
_error(error),
274-
)
318+
try:
319+
if not finalized:
320+
if error is None or _is_stream_close(error):
321+
update_agent_invocation_from_events(invocation, events)
322+
_finish_invocation(
323+
self._handler.stop_invoke_agent, invocation
324+
)
325+
else:
326+
_finish_invocation(
327+
self._handler.fail_invoke_agent,
328+
invocation,
329+
_error(error),
330+
)
331+
finally:
332+
exit_identity()
275333

276334

277335
class AgnoFunctionCallWrapper:

instrumentation-loongsuite/loongsuite-instrumentation-agno/src/opentelemetry/instrumentation/agno/utils.py

Lines changed: 108 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,11 @@
1515
from __future__ import annotations
1616

1717
import json
18+
from contextvars import ContextVar, Token
1819
from dataclasses import asdict, is_dataclass
1920
from typing import Any, Dict, Mapping, Optional, Sequence
2021

22+
from opentelemetry import baggage
2123
from opentelemetry.util.genai.extended_semconv.gen_ai_extended_attributes import (
2224
GEN_AI_SESSION_ID,
2325
GEN_AI_USER_ID,
@@ -44,6 +46,106 @@
4446
"get_skill_reference",
4547
"get_skill_script",
4648
}
49+
_CURRENT_AGNO_RUN_IDENTITY: ContextVar[dict[str, str] | None] = ContextVar(
50+
"agno_current_run_identity",
51+
default=None,
52+
)
53+
54+
55+
def _identity_value(value: Any) -> str | None:
56+
if value is None:
57+
return None
58+
text = str(value).strip()
59+
return text or None
60+
61+
62+
def _first_identity_value(*values: Any) -> str | None:
63+
for value in values:
64+
identity = _identity_value(value)
65+
if identity is not None:
66+
return identity
67+
return None
68+
69+
70+
def _current_baggage_value(key: str) -> str | None:
71+
try:
72+
value = baggage.get_baggage(key)
73+
except Exception:
74+
return None
75+
return _identity_value(value)
76+
77+
78+
def entry_baggage_identity_attributes() -> dict[str, str]:
79+
attributes: dict[str, str] = {}
80+
session_id = _current_baggage_value(GEN_AI_SESSION_ID)
81+
user_id = _current_baggage_value(GEN_AI_USER_ID)
82+
if session_id:
83+
attributes[GEN_AI_SESSION_ID] = session_id
84+
if user_id:
85+
attributes[GEN_AI_USER_ID] = user_id
86+
return attributes
87+
88+
89+
def _framework_identity_attributes(
90+
agent: Any, arguments: Mapping[str, Any]
91+
) -> dict[str, str]:
92+
attributes: dict[str, str] = {}
93+
user_id = _first_identity_value(
94+
arguments.get("user_id"),
95+
getattr(agent, "user_id", None),
96+
)
97+
session_id = _first_identity_value(
98+
arguments.get("session_id"),
99+
getattr(agent, "session_id", None),
100+
)
101+
if user_id:
102+
attributes[GEN_AI_USER_ID] = user_id
103+
if session_id:
104+
attributes[GEN_AI_SESSION_ID] = session_id
105+
return attributes
106+
107+
108+
def agno_run_identity_attributes(
109+
agent: Any, arguments: Mapping[str, Any]
110+
) -> dict[str, str]:
111+
attributes = _framework_identity_attributes(agent, arguments)
112+
attributes.update(entry_baggage_identity_attributes())
113+
return attributes
114+
115+
116+
def set_current_agno_run_identity(
117+
attributes: Mapping[str, Any],
118+
) -> Token[dict[str, str] | None]:
119+
identity = {
120+
key: value
121+
for key in (GEN_AI_SESSION_ID, GEN_AI_USER_ID)
122+
if (value := _identity_value(attributes.get(key))) is not None
123+
}
124+
return _CURRENT_AGNO_RUN_IDENTITY.set(identity or None)
125+
126+
127+
def reset_current_agno_run_identity(
128+
token: Token[dict[str, str] | None],
129+
) -> None:
130+
_CURRENT_AGNO_RUN_IDENTITY.reset(token)
131+
132+
133+
def _current_identity_attributes() -> dict[str, str]:
134+
attributes: dict[str, str] = {}
135+
run_identity = _CURRENT_AGNO_RUN_IDENTITY.get()
136+
if run_identity:
137+
attributes.update(run_identity)
138+
# Re-read baggage so ENTRY identity stays highest priority for child spans
139+
# even if the active OTel context changes while an Agno run is in progress.
140+
attributes.update(entry_baggage_identity_attributes())
141+
return attributes
142+
143+
144+
def _apply_current_identity(invocation: Any) -> str | None:
145+
attributes = _current_identity_attributes()
146+
for key, value in attributes.items():
147+
invocation.attributes[key] = value
148+
return attributes.get(GEN_AI_SESSION_ID)
47149

48150

49151
def _json_default(value: Any) -> Any:
@@ -421,15 +523,8 @@ def create_agent_invocation(
421523
) -> InvokeAgentInvocation:
422524
model = getattr(agent, "model", None)
423525
input_value = arguments.get("input")
424-
user_id = arguments.get("user_id") or getattr(agent, "user_id", None)
425-
session_id = arguments.get("session_id") or getattr(
426-
agent, "session_id", None
427-
)
428-
attributes: dict[str, Any] = {}
429-
if user_id:
430-
attributes[GEN_AI_USER_ID] = str(user_id)
431-
if session_id:
432-
attributes[GEN_AI_SESSION_ID] = str(session_id)
526+
attributes: dict[str, Any] = agno_run_identity_attributes(agent, arguments)
527+
session_id = attributes.get(GEN_AI_SESSION_ID)
433528

434529
invocation = InvokeAgentInvocation(
435530
provider=_PROVIDER,
@@ -568,6 +663,9 @@ def create_llm_invocation(
568663
input_messages=convert_model_messages(arguments.get("messages")),
569664
tool_definitions=convert_tool_definitions(arguments.get("tools")),
570665
)
666+
session_id = _apply_current_identity(invocation)
667+
if session_id and not getattr(invocation, "conversation_id", None):
668+
invocation.conversation_id = session_id
571669

572670
for name in (
573671
"temperature",
@@ -657,6 +755,7 @@ def create_tool_invocation(function_call: Any) -> ExecuteToolInvocation:
657755
tool_type="function",
658756
tool_call_arguments=getattr(function_call, "arguments", None),
659757
)
758+
_apply_current_identity(invocation)
660759
_apply_agno_skill_tool_metadata(invocation, invocation.tool_call_arguments)
661760
return invocation
662761

0 commit comments

Comments
 (0)