Skip to content

Commit 8e515f3

Browse files
committed
GenAI Utils | Agent Invocation Span
1 parent 39ca34a commit 8e515f3

4 files changed

Lines changed: 517 additions & 2 deletions

File tree

util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,13 +80,15 @@
8080
)
8181
from opentelemetry.util.genai.metrics import InvocationMetricsRecorder
8282
from opentelemetry.util.genai.span_utils import (
83+
_apply_agent_finish_attributes,
8384
_apply_creation_finish_attributes,
8485
_apply_error_attributes,
8586
_apply_llm_finish_attributes,
8687
_maybe_emit_llm_event,
8788
)
8889
from opentelemetry.util.genai.types import (
8990
AgentCreation,
91+
AgentInvocation,
9092
Error,
9193
LLMInvocation,
9294
)
@@ -213,6 +215,75 @@ def llm(
213215
raise
214216
self.stop_llm(invocation)
215217

218+
# ---- Agent invocation lifecycle ----
219+
220+
def start_agent(
221+
self,
222+
invocation: AgentInvocation,
223+
) -> AgentInvocation:
224+
"""Start an agent invocation and create a pending span entry."""
225+
span_name = f"{invocation.operation_name} {invocation.agent_name}".strip()
226+
kind = SpanKind.CLIENT if invocation.is_remote else SpanKind.INTERNAL
227+
span = self._tracer.start_span(
228+
name=span_name,
229+
kind=kind,
230+
)
231+
invocation.monotonic_start_s = timeit.default_timer()
232+
invocation.span = span
233+
invocation.context_token = otel_context.attach(
234+
set_span_in_context(span)
235+
)
236+
return invocation
237+
238+
def stop_agent(self, invocation: AgentInvocation) -> AgentInvocation: # pylint: disable=no-self-use
239+
"""Finalize an agent invocation successfully and end its span."""
240+
if invocation.context_token is None or invocation.span is None:
241+
return invocation
242+
243+
span = invocation.span
244+
_apply_agent_finish_attributes(span, invocation)
245+
otel_context.detach(invocation.context_token)
246+
span.end()
247+
return invocation
248+
249+
def fail_agent( # pylint: disable=no-self-use
250+
self, invocation: AgentInvocation, error: Error
251+
) -> AgentInvocation:
252+
"""Fail an agent invocation and end its span with error status."""
253+
if invocation.context_token is None or invocation.span is None:
254+
return invocation
255+
256+
span = invocation.span
257+
_apply_agent_finish_attributes(span, invocation)
258+
_apply_error_attributes(span, error)
259+
otel_context.detach(invocation.context_token)
260+
span.end()
261+
return invocation
262+
263+
@contextmanager
264+
def agent(
265+
self, invocation: AgentInvocation | None = None
266+
) -> Iterator[AgentInvocation]:
267+
"""Context manager for agent invocations.
268+
269+
Only set data attributes on the invocation object, do not modify the span or context.
270+
271+
Starts the span on entry. On normal exit, finalizes the invocation and ends the span.
272+
If an exception occurs inside the context, marks the span as error, ends it, and
273+
re-raises the original exception.
274+
"""
275+
if invocation is None:
276+
invocation = AgentInvocation()
277+
self.start_agent(invocation)
278+
try:
279+
yield invocation
280+
except Exception as exc:
281+
self.fail_agent(
282+
invocation, Error(message=str(exc), type=type(exc))
283+
)
284+
raise
285+
self.stop_agent(invocation)
286+
216287
# ---- Agent creation lifecycle ----
217288

218289
def start_create_agent(

util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
from opentelemetry.trace.status import Status, StatusCode
3434
from opentelemetry.util.genai.types import (
3535
AgentCreation,
36+
AgentInvocation,
3637
Error,
3738
InputMessage,
3839
LLMInvocation,
@@ -62,6 +63,16 @@ def _agent_attr(name: str, fallback: str) -> str:
6263
_GEN_AI_AGENT_VERSION = _agent_attr(
6364
"GEN_AI_AGENT_VERSION", "gen_ai.agent.version"
6465
)
66+
_GEN_AI_CONVERSATION_ID = _agent_attr(
67+
"GEN_AI_CONVERSATION_ID", "gen_ai.conversation.id"
68+
)
69+
_GEN_AI_DATA_SOURCE_ID = _agent_attr(
70+
"GEN_AI_DATA_SOURCE_ID", "gen_ai.data_source.id"
71+
)
72+
_GEN_AI_OUTPUT_TYPE = _agent_attr("GEN_AI_OUTPUT_TYPE", "gen_ai.output.type")
73+
_GEN_AI_TOOL_DEFINITIONS = _agent_attr(
74+
"GEN_AI_TOOL_DEFINITIONS", "gen_ai.tool.definitions"
75+
)
6576

6677

6778
def _get_llm_common_attributes(
@@ -324,6 +335,149 @@ def _get_base_agent_span_name(agent: _BaseAgent) -> str:
324335
return agent.operation_name
325336

326337

338+
def _get_agent_common_attributes(
339+
invocation: AgentInvocation,
340+
) -> dict[str, Any]:
341+
"""Get common agent invocation attributes shared by finish() and error() paths."""
342+
attrs = _get_base_agent_common_attributes(invocation)
343+
344+
# Invoke-specific conditionally required attributes
345+
invoke_attrs = (
346+
(_GEN_AI_CONVERSATION_ID, invocation.conversation_id),
347+
(_GEN_AI_DATA_SOURCE_ID, invocation.data_source_id),
348+
(_GEN_AI_OUTPUT_TYPE, invocation.output_type),
349+
)
350+
attrs.update(
351+
{key: value for key, value in invoke_attrs if value is not None}
352+
)
353+
354+
return attrs
355+
356+
357+
def _get_agent_span_name(invocation: AgentInvocation) -> str:
358+
"""Get the span name for an agent invocation."""
359+
return _get_base_agent_span_name(invocation)
360+
361+
362+
def _get_agent_request_attributes(
363+
invocation: AgentInvocation,
364+
) -> dict[str, Any]:
365+
"""Get GenAI request semantic convention attributes for agent invocation."""
366+
optional_attrs = (
367+
(GenAI.GEN_AI_REQUEST_TEMPERATURE, invocation.temperature),
368+
(GenAI.GEN_AI_REQUEST_TOP_P, invocation.top_p),
369+
(GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY, invocation.frequency_penalty),
370+
(GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY, invocation.presence_penalty),
371+
(GenAI.GEN_AI_REQUEST_MAX_TOKENS, invocation.max_tokens),
372+
(GenAI.GEN_AI_REQUEST_STOP_SEQUENCES, invocation.stop_sequences),
373+
(GenAI.GEN_AI_REQUEST_SEED, invocation.seed),
374+
(GenAI.GEN_AI_REQUEST_CHOICE_COUNT, invocation.choice_count),
375+
)
376+
377+
return {key: value for key, value in optional_attrs if value is not None}
378+
379+
380+
def _get_agent_response_attributes(
381+
invocation: AgentInvocation,
382+
) -> dict[str, Any]:
383+
"""Get GenAI response semantic convention attributes for agent invocation."""
384+
finish_reasons: list[str] | None
385+
if invocation.finish_reasons is not None:
386+
finish_reasons = invocation.finish_reasons
387+
elif invocation.output_messages:
388+
finish_reasons = [
389+
message.finish_reason
390+
for message in invocation.output_messages
391+
if message.finish_reason
392+
]
393+
else:
394+
finish_reasons = None
395+
396+
unique_finish_reasons = (
397+
sorted(set(finish_reasons)) if finish_reasons else None
398+
)
399+
400+
optional_attrs = (
401+
(
402+
GenAI.GEN_AI_RESPONSE_FINISH_REASONS,
403+
unique_finish_reasons if unique_finish_reasons else None,
404+
),
405+
(GenAI.GEN_AI_RESPONSE_MODEL, invocation.response_model_name),
406+
(GenAI.GEN_AI_RESPONSE_ID, invocation.response_id),
407+
(GenAI.GEN_AI_USAGE_INPUT_TOKENS, invocation.input_tokens),
408+
(GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, invocation.output_tokens),
409+
)
410+
411+
return {key: value for key, value in optional_attrs if value is not None}
412+
413+
414+
def _get_agent_messages_attributes_for_span(
415+
input_messages: list[InputMessage],
416+
output_messages: list[OutputMessage],
417+
system_instruction: list[MessagePart] | None = None,
418+
tool_definitions: list[dict[str, Any]] | None = None,
419+
) -> dict[str, Any]:
420+
"""Get message attributes formatted for span (JSON string format) for agent invocation."""
421+
if not is_experimental_mode() or get_content_capturing_mode() not in (
422+
ContentCapturingMode.SPAN_ONLY,
423+
ContentCapturingMode.SPAN_AND_EVENT,
424+
):
425+
return {}
426+
427+
optional_attrs = (
428+
(
429+
GenAI.GEN_AI_INPUT_MESSAGES,
430+
gen_ai_json_dumps([asdict(m) for m in input_messages])
431+
if input_messages
432+
else None,
433+
),
434+
(
435+
GenAI.GEN_AI_OUTPUT_MESSAGES,
436+
gen_ai_json_dumps([asdict(m) for m in output_messages])
437+
if output_messages
438+
else None,
439+
),
440+
(
441+
GenAI.GEN_AI_SYSTEM_INSTRUCTIONS,
442+
gen_ai_json_dumps([asdict(p) for p in system_instruction])
443+
if system_instruction
444+
else None,
445+
),
446+
(
447+
_GEN_AI_TOOL_DEFINITIONS,
448+
gen_ai_json_dumps(tool_definitions)
449+
if tool_definitions
450+
else None,
451+
),
452+
)
453+
454+
return {key: value for key, value in optional_attrs if value is not None}
455+
456+
457+
def _apply_agent_finish_attributes(
458+
span: Span, invocation: AgentInvocation
459+
) -> None:
460+
"""Apply attributes/messages common to agent finish() paths."""
461+
span.update_name(_get_agent_span_name(invocation))
462+
463+
attributes: dict[str, Any] = {}
464+
attributes.update(_get_agent_common_attributes(invocation))
465+
attributes.update(_get_agent_request_attributes(invocation))
466+
attributes.update(_get_agent_response_attributes(invocation))
467+
attributes.update(
468+
_get_agent_messages_attributes_for_span(
469+
invocation.input_messages,
470+
invocation.output_messages,
471+
invocation.system_instruction,
472+
invocation.tool_definitions,
473+
)
474+
)
475+
attributes.update(invocation.attributes)
476+
477+
if attributes:
478+
span.set_attributes(attributes)
479+
480+
327481
def _get_creation_common_attributes(
328482
creation: AgentCreation,
329483
) -> dict[str, Any]:
@@ -375,7 +529,12 @@ def _apply_creation_finish_attributes(
375529
"_maybe_emit_llm_event",
376530
"_get_base_agent_common_attributes",
377531
"_get_base_agent_span_name",
532+
"_apply_agent_finish_attributes",
378533
"_apply_creation_finish_attributes",
534+
"_get_agent_common_attributes",
535+
"_get_agent_request_attributes",
536+
"_get_agent_response_attributes",
537+
"_get_agent_span_name",
379538
"_get_creation_common_attributes",
380539
"_get_creation_span_name",
381540
]

util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,64 @@ class AgentCreation(_BaseAgent):
298298
operation_name: str = "create_agent"
299299

300300

301+
@dataclass
302+
class AgentInvocation(_BaseAgent):
303+
"""
304+
Represents an agent invocation (invoke_agent operation).
305+
306+
Follows semconv for GenAI agent spans:
307+
https://github.com/open-telemetry/semantic-conventions/blob/main/docs/gen-ai/gen-ai-agent-spans.md#invoke-agent-span
308+
309+
When creating an AgentInvocation object, only update the data attributes.
310+
The span and context_token attributes are set by the TelemetryHandler.
311+
"""
312+
313+
# Override default operation name
314+
operation_name: str = "invoke_agent"
315+
316+
# Invoke-specific request attributes (Cond. Required)
317+
conversation_id: str | None = None
318+
data_source_id: str | None = None
319+
output_type: str | None = None
320+
321+
# Request parameters (Recommended)
322+
temperature: float | None = None
323+
top_p: float | None = None
324+
frequency_penalty: float | None = None
325+
presence_penalty: float | None = None
326+
max_tokens: int | None = None
327+
stop_sequences: list[str] | None = None
328+
seed: int | None = None
329+
choice_count: int | None = None
330+
331+
# Response (Recommended)
332+
response_model_name: str | None = None
333+
response_id: str | None = None
334+
finish_reasons: list[str] | None = None
335+
input_tokens: int | None = None
336+
output_tokens: int | None = None
337+
338+
# Content (Opt-In) — input/output messages and tool definitions
339+
input_messages: list[InputMessage] = field(
340+
default_factory=_new_input_messages
341+
)
342+
output_messages: list[OutputMessage] = field(
343+
default_factory=_new_output_messages
344+
)
345+
tool_definitions: list[dict[str, Any]] | None = None
346+
347+
# Span kind: CLIENT for remote agents, INTERNAL for in-process agents
348+
is_remote: bool = True
349+
350+
metric_attributes: dict[str, Any] = field(
351+
default_factory=_new_str_any_dict
352+
)
353+
"""
354+
Additional attributes to set on metrics. Must be of a low cardinality.
355+
These attributes will not be set on spans or events.
356+
"""
357+
358+
301359
@dataclass
302360
class Error:
303361
message: str

0 commit comments

Comments
 (0)