diff --git a/util/opentelemetry-util-genai/CHANGELOG.md b/util/opentelemetry-util-genai/CHANGELOG.md index f64092a697..8f236c5d59 100644 --- a/util/opentelemetry-util-genai/CHANGELOG.md +++ b/util/opentelemetry-util-genai/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased +- Add `_BaseAgent` shared base class and `AgentCreation` type for agent creation lifecycle spans + ([#4217](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4217)) + ## Version 0.3b0 (2026-02-20) - Add `gen_ai.tool_definitions` to completion hook ([#4181](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4181)) diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py index 54e626deaa..9d9672ae71 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/handler.py @@ -80,11 +80,16 @@ ) from opentelemetry.util.genai.metrics import InvocationMetricsRecorder from opentelemetry.util.genai.span_utils import ( + _apply_creation_finish_attributes, _apply_error_attributes, _apply_llm_finish_attributes, _maybe_emit_llm_event, ) -from opentelemetry.util.genai.types import Error, LLMInvocation +from opentelemetry.util.genai.types import ( + AgentCreation, + Error, + LLMInvocation, +) from opentelemetry.util.genai.version import __version__ @@ -208,6 +213,70 @@ def llm( raise self.stop_llm(invocation) + # ---- Agent lifecycle ---- + + def start_agent( + self, + agent: AgentCreation, + ) -> AgentCreation: + """Start an agent operation (create or invoke) and create a pending span entry.""" + span_name = f"{agent.operation_name} {agent.name}".strip() + span = self._tracer.start_span( + name=span_name, + kind=SpanKind.CLIENT, + ) + agent.monotonic_start_s = timeit.default_timer() + agent.span = span + agent.context_token = otel_context.attach(set_span_in_context(span)) + return agent + + def stop_agent(self, agent: AgentCreation) -> AgentCreation: # pylint: disable=no-self-use + """Finalize an agent operation successfully and end its span.""" + if agent.context_token is None or agent.span is None: + return agent + + span = agent.span + _apply_creation_finish_attributes(span, agent) + otel_context.detach(agent.context_token) + span.end() + return agent + + def fail_agent( # pylint: disable=no-self-use + self, agent: AgentCreation, error: Error + ) -> AgentCreation: + """Fail an agent operation and end its span with error status.""" + if agent.context_token is None or agent.span is None: + return agent + + span = agent.span + _apply_creation_finish_attributes(span, agent) + _apply_error_attributes(span, error) + otel_context.detach(agent.context_token) + span.end() + return agent + + @contextmanager + def create_agent( + self, creation: AgentCreation | None = None + ) -> Iterator[AgentCreation]: + """Context manager for agent creation. + + Only set data attributes on the creation object, do not modify the span or context. + + Starts the span on entry. On normal exit, finalizes the creation and ends the span. + If an exception occurs inside the context, marks the span as error, ends it, and + re-raises the original exception. + """ + if creation is None: + creation = AgentCreation() + self.start_agent(creation) + try: + yield creation + except Exception as exc: + self.fail_agent(creation, Error(message=str(exc), type=type(exc))) + raise + self.stop_agent(creation) + def get_telemetry_handler( tracer_provider: TracerProvider | None = None, diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py index 889994436f..aeaceb931a 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/span_utils.py @@ -32,11 +32,13 @@ from opentelemetry.trace.propagation import set_span_in_context from opentelemetry.trace.status import Status, StatusCode from opentelemetry.util.genai.types import ( + AgentCreation, Error, InputMessage, LLMInvocation, MessagePart, OutputMessage, + _BaseAgent, ) from opentelemetry.util.genai.utils import ( ContentCapturingMode, @@ -279,6 +281,75 @@ def _get_llm_response_attributes( return {key: value for key, value in optional_attrs if value is not None} +def _get_base_agent_common_attributes( + agent: _BaseAgent, +) -> dict[str, Any]: + """Get common attributes shared by all agent operations (invoke_agent, create_agent).""" + optional_attrs = ( + (GenAI.GEN_AI_REQUEST_MODEL, agent.model), + (GenAI.GEN_AI_PROVIDER_NAME, agent.provider), + (GenAI.GEN_AI_AGENT_NAME, agent.name), + (GenAI.GEN_AI_AGENT_ID, agent.agent_id), + (GenAI.GEN_AI_AGENT_DESCRIPTION, agent.description), + ("gen_ai.agent.version", agent.version), + (server_attributes.SERVER_ADDRESS, agent.server_address), + (server_attributes.SERVER_PORT, agent.server_port), + ) + + return { + GenAI.GEN_AI_OPERATION_NAME: agent.operation_name, + **{key: value for key, value in optional_attrs if value is not None}, + } + + +def _get_base_agent_span_name(agent: _BaseAgent) -> str: + """Get the span name for any agent operation.""" + if agent.name: + return f"{agent.operation_name} {agent.name}" + return agent.operation_name + + +def _get_creation_common_attributes( + creation: AgentCreation, +) -> dict[str, Any]: + """Get common agent creation attributes.""" + return _get_base_agent_common_attributes(creation) + + +def _get_creation_span_name(creation: AgentCreation) -> str: + """Get the span name for an agent creation.""" + return _get_base_agent_span_name(creation) + + +def _apply_creation_finish_attributes( + span: Span, creation: AgentCreation +) -> None: + """Apply attributes common to agent creation finish() paths.""" + span.update_name(_get_creation_span_name(creation)) + + attributes: dict[str, Any] = {} + attributes.update(_get_creation_common_attributes(creation)) + + # System instructions (Opt-In) + if ( + is_experimental_mode() + and get_content_capturing_mode() + in ( + ContentCapturingMode.SPAN_ONLY, + ContentCapturingMode.SPAN_AND_EVENT, + ) + and creation.system_instructions + ): + attributes[GenAI.GEN_AI_SYSTEM_INSTRUCTIONS] = gen_ai_json_dumps( + [asdict(p) for p in creation.system_instructions] + ) + + attributes.update(creation.attributes) + + if attributes: + span.set_attributes(attributes) + + __all__ = [ "_apply_llm_finish_attributes", "_apply_error_attributes", @@ -287,4 +358,9 @@ def _get_llm_response_attributes( "_get_llm_response_attributes", "_get_llm_span_name", "_maybe_emit_llm_event", + "_get_base_agent_common_attributes", + "_get_base_agent_span_name", + "_apply_creation_finish_attributes", + "_get_creation_common_attributes", + "_get_creation_span_name", ] diff --git a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py index 045a65b372..c369cae505 100644 --- a/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py +++ b/util/opentelemetry-util-genai/src/opentelemetry/util/genai/types.py @@ -256,6 +256,57 @@ class LLMInvocation(GenAIInvocation): monotonic_start_s: float | None = None +@dataclass +class _BaseAgent(GenAIInvocation): + """Shared base class for agent lifecycle types. Do not instantiate + directly — use AgentCreation (or AgentInvocation in future). + The span and context_token attributes are set by the TelemetryHandler. + """ + + # Agent identity + name: str | None = None + agent_id: str | None = None + description: str | None = None + version: str | None = None + + # Operation + operation_name: str = "" + provider: str | None = None + + # Request + model: str | None = None # primary model if applicable + + # Content (Opt-In) + system_instructions: list[MessagePart] = field( + default_factory=_new_system_instruction + ) + + # Server + server_address: str | None = None + server_port: int | None = None + + attributes: dict[str, Any] = field(default_factory=_new_str_any_dict) + """ + Additional attributes to set on spans and/or events. + """ + # Monotonic start time in seconds (from timeit.default_timer) used + # for duration calculations to avoid mixing clock sources. This is + # populated by the TelemetryHandler when starting an invocation. + monotonic_start_s: float | None = None + + +@dataclass +class AgentCreation(_BaseAgent): + """ + Represents an agent creation/initialization. When creating an AgentCreation + object, only update the data attributes. The span and context_token + attributes are set by the TelemetryHandler. + """ + + # Override default operation name + operation_name: str = GenAI.GenAiOperationNameValues.CREATE_AGENT.value + + @dataclass class Error: message: str diff --git a/util/opentelemetry-util-genai/tests/test_handler_agent.py b/util/opentelemetry-util-genai/tests/test_handler_agent.py new file mode 100644 index 0000000000..5c005fb324 --- /dev/null +++ b/util/opentelemetry-util-genai/tests/test_handler_agent.py @@ -0,0 +1,172 @@ +from __future__ import annotations + +from unittest import TestCase + +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) +from opentelemetry.trace import SpanKind +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.types import ( + AgentCreation, + Error, +) + + +class TestAgentCreationHandler(TestCase): + def setUp(self) -> None: + self.span_exporter = InMemorySpanExporter() + self.tracer_provider = TracerProvider() + self.tracer_provider.add_span_processor( + SimpleSpanProcessor(self.span_exporter) + ) + + def _make_handler(self) -> TelemetryHandler: + return TelemetryHandler( + tracer_provider=self.tracer_provider, + ) + + def test_start_stop_create_agent(self) -> None: + handler = self._make_handler() + creation = AgentCreation( + name="New Agent", + agent_id="agent-new-1", + provider="openai", + model="gpt-4", + ) + handler.start_agent(creation) + handler.stop_agent(creation) + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + span = spans[0] + self.assertEqual(span.name, "create_agent New Agent") + self.assertEqual( + span.attributes[GenAI.GEN_AI_OPERATION_NAME], "create_agent" + ) + self.assertEqual(span.attributes[GenAI.GEN_AI_AGENT_NAME], "New Agent") + + def test_create_agent_span_kind_is_client(self) -> None: + handler = self._make_handler() + creation = AgentCreation(name="Client Agent") + handler.start_agent(creation) + handler.stop_agent(creation) + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(spans[0].kind, SpanKind.CLIENT) + + def test_create_agent_with_all_base_attributes(self) -> None: + handler = self._make_handler() + creation = AgentCreation( + name="Full Agent", + agent_id="agent-123", + description="A test agent", + version="1.0.0", + provider="openai", + model="gpt-4", + server_address="api.openai.com", + server_port=443, + ) + handler.start_agent(creation) + handler.stop_agent(creation) + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + attrs = spans[0].attributes + self.assertEqual(attrs[GenAI.GEN_AI_OPERATION_NAME], "create_agent") + self.assertEqual(attrs[GenAI.GEN_AI_AGENT_NAME], "Full Agent") + self.assertEqual(attrs[GenAI.GEN_AI_AGENT_ID], "agent-123") + self.assertEqual(attrs[GenAI.GEN_AI_AGENT_DESCRIPTION], "A test agent") + self.assertEqual(attrs["gen_ai.agent.version"], "1.0.0") + self.assertEqual(attrs[GenAI.GEN_AI_PROVIDER_NAME], "openai") + self.assertEqual(attrs[GenAI.GEN_AI_REQUEST_MODEL], "gpt-4") + + def test_fail_create_agent(self) -> None: + handler = self._make_handler() + creation = AgentCreation(name="Bad Agent") + handler.start_agent(creation) + error = Error(message="creation failed", type=RuntimeError) + handler.fail_agent(creation, error) + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].status.description, "creation failed") + self.assertEqual(spans[0].attributes.get("error.type"), "RuntimeError") + + def test_create_agent_context_manager(self) -> None: + handler = self._make_handler() + creation = AgentCreation( + name="CM Agent", + provider="openai", + ) + with handler.create_agent(creation) as c: + c.agent_id = "assigned-id" + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].name, "create_agent CM Agent") + + def test_create_agent_context_manager_error(self) -> None: + handler = self._make_handler() + with self.assertRaises(TypeError): + with handler.create_agent(AgentCreation(name="Err")): + raise TypeError("bad type") + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + self.assertEqual(spans[0].attributes.get("error.type"), "TypeError") + + def test_create_agent_context_manager_default(self) -> None: + handler = self._make_handler() + with handler.create_agent() as c: + c.name = "Dynamic Agent" + c.provider = "openai" + + spans = self.span_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + def test_stop_agent_without_start_is_noop(self) -> None: + handler = self._make_handler() + creation = AgentCreation(name="Not Started") + result = handler.stop_agent(creation) + self.assertIs(result, creation) + self.assertEqual(len(self.span_exporter.get_finished_spans()), 0) + + def test_fail_agent_without_start_is_noop(self) -> None: + handler = self._make_handler() + creation = AgentCreation(name="Not Started") + error = Error(message="boom", type=RuntimeError) + result = handler.fail_agent(creation, error) + self.assertIs(result, creation) + self.assertEqual(len(self.span_exporter.get_finished_spans()), 0) + + +class TestAgentCreationTypes(TestCase): + """Unit tests for the _BaseAgent and AgentCreation dataclasses.""" + + def test_agent_creation_defaults(self) -> None: + creation = AgentCreation() + self.assertEqual(creation.operation_name, "create_agent") + self.assertIsNone(creation.name) + self.assertIsNone(creation.agent_id) + self.assertIsNone(creation.description) + self.assertIsNone(creation.version) + self.assertIsNone(creation.provider) + self.assertIsNone(creation.model) + self.assertEqual(creation.system_instructions, []) + self.assertIsNone(creation.server_address) + self.assertIsNone(creation.server_port) + self.assertIsNone(creation.span) + self.assertIsNone(creation.context_token) + + def test_agent_creation_custom_attributes(self) -> None: + creation = AgentCreation( + name="Custom", + attributes={"custom.key": "custom_value"}, + ) + self.assertEqual(creation.attributes["custom.key"], "custom_value")