diff --git a/README-zh.md b/README-zh.md index 5d0e02a8b..36f806893 100644 --- a/README-zh.md +++ b/README-zh.md @@ -42,6 +42,7 @@ LoongSuite Python Agent 同时也是上游 [OTel Python Agent](https://github.co | [LiteLLM](https://github.com/BerriAI/litellm) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-litellm/README.md) | [PyPI](https://pypi.org/project/loongsuite-instrumentation-litellm/) | | [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-mcp/README.md) | in dev | | [Mem0](https://github.com/mem0ai/mem0) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-mem0/README.md) | [PyPI](https://pypi.org/project/loongsuite-instrumentation-mem0/) | +| [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/README.rst) | in dev | **发行版与辅助组件:** diff --git a/README.md b/README.md index b803ddbf9..aa1d0cffd 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Source tree: [`instrumentation-loongsuite/`](instrumentation-loongsuite). | [LiteLLM](https://github.com/BerriAI/litellm) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-litellm/README.md) | [PyPI](https://pypi.org/project/loongsuite-instrumentation-litellm/) | | [MCP Python SDK](https://github.com/modelcontextprotocol/python-sdk) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-mcp/README.md) | in dev | | [Mem0](https://github.com/mem0ai/mem0) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-mem0/README.md) | [PyPI](https://pypi.org/project/loongsuite-instrumentation-mem0/) | +| [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) | [GUIDE](instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/README.rst) | in dev | **Distro and helpers:** diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/CHANGELOG.md b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/CHANGELOG.md new file mode 100644 index 000000000..a58d77388 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Added + +- Initialize the instrumentation for OpenAI Agents SDK + ([#161](https://github.com/alibaba/loongsuite-python-agent/pull/161)) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/README.rst b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/README.rst new file mode 100644 index 000000000..9e50f68d4 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/README.rst @@ -0,0 +1,49 @@ +OpenTelemetry OpenAI Agents SDK Instrumentation +================================================ + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/loongsuite-instrumentation-openai-agents.svg + :target: https://pypi.org/project/loongsuite-instrumentation-openai-agents/ + +This library provides automatic instrumentation for the +`OpenAI Agents SDK `_, +capturing telemetry data for agent runs, tool executions, LLM generations, +handoffs, and guardrails. + +Installation +------------ + +:: + + pip install loongsuite-instrumentation-openai-agents + +Usage +----- + +.. code-block:: python + + from opentelemetry.instrumentation.openai_agents import OpenAIAgentsInstrumentor + + OpenAIAgentsInstrumentor().instrument() + + # Your OpenAI Agents SDK code works as normal + from agents import Agent, Runner + + agent = Agent(name="assistant", instructions="You are helpful.") + result = Runner.run_sync(agent, "Hello!") + +The instrumentation automatically captures: + +- Agent invocation spans (``invoke_agent``) +- Tool execution spans (``execute_tool``) +- LLM generation spans (``chat``) +- Agent handoff spans +- Guardrail execution spans + +References +---------- + +* `OpenTelemetry Project `_ +* `OpenAI Agents SDK documentation `_ +* `OpenTelemetry GenAI Semantic Conventions `_ diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/pyproject.toml b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/pyproject.toml new file mode 100644 index 000000000..7828b68c2 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/pyproject.toml @@ -0,0 +1,59 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "loongsuite-instrumentation-openai-agents" +dynamic = ["version"] +description = "LoongSuite OpenAI Agents SDK instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.10" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, + { name = "minimAluminiumalism", email = "caixuesen@outlook.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", +] +dependencies = [ + "opentelemetry-api ~= 1.37", + "opentelemetry-instrumentation ~= 0.58b0", + "opentelemetry-semantic-conventions ~= 0.58b0", + "opentelemetry-util-genai", +] + +[project.optional-dependencies] +instruments = [ + "openai-agents >= 0.0.7", +] + +[project.entry-points.opentelemetry_instrumentor] +openai_agents = "opentelemetry.instrumentation.openai_agents:OpenAIAgentsInstrumentor" + +[project.urls] +Homepage = "https://github.com/alibaba/loongsuite-python-agent/tree/main/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents" +Repository = "https://github.com/alibaba/loongsuite-python-agent" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/openai_agents/version.py" + +[tool.hatch.build.targets.sdist] +include = [ + "/src", + "/tests", +] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] + +[tool.pytest.ini_options] +asyncio_mode = "auto" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py new file mode 100644 index 000000000..6add18004 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py @@ -0,0 +1,140 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +OpenTelemetry OpenAI Agents SDK Instrumentation +================================================ + +This package provides automatic instrumentation for the OpenAI Agents SDK, +capturing telemetry data for agent runs, tool executions, LLM generations, +handoffs, and guardrails. + +Usage +----- + +Basic instrumentation:: + + from opentelemetry.instrumentation.openai_agents import ( + OpenAIAgentsInstrumentor, + ) + + OpenAIAgentsInstrumentor().instrument() + + from agents import Agent, Runner + + agent = Agent(name="assistant", instructions="You are helpful.") + result = Runner.run_sync(agent, "Hello!") + +The instrumentation leverages the SDK's built-in ``TracingProcessor`` +interface to register an OpenTelemetry bridge, so all native SDK tracing +points are automatically captured without monkey-patching. +""" + +import logging +import os +from typing import Any, Collection, Optional + +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.openai_agents.package import ( + _instruments, +) +from opentelemetry.instrumentation.openai_agents.version import ( + __version__, +) +from opentelemetry.util.genai.extended_handler import ( + ExtendedTelemetryHandler, +) + +logger = logging.getLogger(__name__) + +_ENV_CAPTURE_CONTENT = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + + +def _should_capture_content() -> bool: + val = os.environ.get(_ENV_CAPTURE_CONTENT, "").strip().lower() + return val not in ("false", "0", "no", "off", "") + + +class OpenAIAgentsInstrumentor(BaseInstrumentor): + """Instrumentor for the OpenAI Agents SDK. + + Registers an OpenTelemetry-aware ``TracingProcessor`` with the + SDK's global trace provider so that every agent run, tool call, + LLM generation, handoff, and guardrail execution is automatically + exported as an OTel span. + """ + + _handler: Optional[ExtendedTelemetryHandler] = None + _processor: Optional[Any] = None + + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs: Any) -> None: + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + logger_provider = kwargs.get("logger_provider") + + OpenAIAgentsInstrumentor._handler = ExtendedTelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + + capture_content = _should_capture_content() + + from agents.tracing import ( # noqa: PLC0415 + add_trace_processor, + ) + + from opentelemetry.instrumentation.openai_agents._processor import ( # noqa: PLC0415 + OTelTracingProcessor, + ) + + processor = OTelTracingProcessor( + handler=OpenAIAgentsInstrumentor._handler, + capture_content=capture_content, + ) + OpenAIAgentsInstrumentor._processor = processor + add_trace_processor(processor) + + def _uninstrument(self, **kwargs: Any) -> None: + processor = OpenAIAgentsInstrumentor._processor + if processor is None: + return + + try: + from agents.tracing.setup import ( # noqa: PLC0415 + get_trace_provider, + ) + + provider = get_trace_provider() + if hasattr(provider, "_multi_processor"): + mp = provider._multi_processor + if hasattr(mp, "_processors"): + procs = mp._processors + if processor in procs: + procs.remove(processor) + except Exception as e: + logger.debug("Failed to remove processor: %s", e) + + processor.shutdown() + OpenAIAgentsInstrumentor._processor = None + OpenAIAgentsInstrumentor._handler = None + + +__all__ = [ + "__version__", + "OpenAIAgentsInstrumentor", +] diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/_processor.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/_processor.py new file mode 100644 index 000000000..551d92b32 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/_processor.py @@ -0,0 +1,493 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from __future__ import annotations + +import json +import logging +from typing import Any + +from agents.tracing.processor_interface import TracingProcessor +from agents.tracing.span_data import ( + AgentSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, +) +from agents.tracing.spans import Span +from agents.tracing.traces import Trace + +from opentelemetry import context as otel_context +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) +from opentelemetry.trace import ( + Span as OTelSpan, +) +from opentelemetry.trace import ( + SpanKind, + StatusCode, + set_span_in_context, +) +from opentelemetry.util.genai.extended_handler import ( + ExtendedTelemetryHandler, +) +from opentelemetry.util.genai.handler import _safe_detach + +logger = logging.getLogger(__name__) + +_PROVIDER_NAME = "openai_agents" +_ATTR_HANDOFF_FROM = "gen_ai.openai.agents.handoff.from_agent" +_ATTR_HANDOFF_TO = "gen_ai.openai.agents.handoff.to_agent" +_ATTR_GUARDRAIL_NAME = "gen_ai.openai.agents.guardrail.name" +_ATTR_GUARDRAIL_TRIGGERED = "gen_ai.openai.agents.guardrail.triggered" + + +def _dont_throw(func): + """Decorator that catches and logs exceptions to avoid + crashing the user application.""" + + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except Exception: + logger.debug("Error in %s", func.__name__, exc_info=True) + + return wrapper + + +def _safe_json(obj: Any) -> str | None: + if obj is None: + return None + try: + return json.dumps(obj, default=str, ensure_ascii=False) + except (TypeError, ValueError): + return str(obj) + + +class OTelTracingProcessor(TracingProcessor): + """Bridges openai-agents SDK tracing to OpenTelemetry spans. + + Implements the SDK's TracingProcessor interface and translates + each SDK span into an OTel span following the GenAI semantic + conventions. + """ + + def __init__( + self, + handler: ExtendedTelemetryHandler, + capture_content: bool = True, + ): + self._handler = handler + self._capture_content = capture_content + # SDK span_id -> (OTel span, context token) + self._span_map: dict[str, tuple[OTelSpan, object | None]] = {} + # SDK trace_id -> (OTel span, context token) + self._trace_map: dict[str, tuple[OTelSpan, object | None]] = {} + + # ------------------------------------------------------------------ + # Trace lifecycle + # ------------------------------------------------------------------ + + @_dont_throw + def on_trace_start(self, trace: Trace) -> None: + span_name = f"invoke_workflow {trace.name}" + otel_span = self._handler._tracer.start_span( + name=span_name, kind=SpanKind.INTERNAL + ) + otel_span.set_attribute(GenAI.GEN_AI_OPERATION_NAME, "invoke_workflow") + otel_span.set_attribute(GenAI.GEN_AI_SYSTEM, _PROVIDER_NAME) + otel_span.set_attribute(GenAI.GEN_AI_PROVIDER_NAME, _PROVIDER_NAME) + ctx_token = otel_context.attach(set_span_in_context(otel_span)) + self._trace_map[trace.trace_id] = (otel_span, ctx_token) + + @_dont_throw + def on_trace_end(self, trace: Trace) -> None: + entry = self._trace_map.pop(trace.trace_id, None) + if entry is None: + return + otel_span, ctx_token = entry + otel_span.end() + if ctx_token is not None: + _safe_detach(ctx_token) + + # ------------------------------------------------------------------ + # Span lifecycle + # ------------------------------------------------------------------ + + @_dont_throw + def on_span_start(self, span: Span[Any]) -> None: + span_data = span.span_data + parent_ctx = None + + # Resolve parent: SDK parent_id -> OTel parent span context + if span.parent_id and span.parent_id in self._span_map: + parent_otel_span, _ = self._span_map[span.parent_id] + parent_ctx = set_span_in_context(parent_otel_span) + elif span.trace_id in self._trace_map: + parent_otel_span, _ = self._trace_map[span.trace_id] + parent_ctx = set_span_in_context(parent_otel_span) + + otel_span = self._create_span_for(span_data, parent_ctx) + if otel_span is None: + return + + ctx_token = otel_context.attach(set_span_in_context(otel_span)) + self._span_map[span.span_id] = (otel_span, ctx_token) + + @_dont_throw + def on_span_end(self, span: Span[Any]) -> None: + entry = self._span_map.pop(span.span_id, None) + if entry is None: + return + otel_span, ctx_token = entry + + self._apply_end_attributes(otel_span, span) + + if span.error: + error_msg = span.error.get("message", "") + error_type = span.error.get("type", "Exception") + otel_span.set_status(StatusCode.ERROR, error_msg) + otel_span.set_attribute("error.type", error_type) + + otel_span.end() + if ctx_token is not None: + _safe_detach(ctx_token) + + # ------------------------------------------------------------------ + # Span creation per type + # ------------------------------------------------------------------ + + def _create_span_for( + self, + span_data: Any, + parent_ctx: Any | None, + ) -> OTelSpan | None: + if isinstance(span_data, AgentSpanData): + return self._create_agent_span(span_data, parent_ctx) + if isinstance(span_data, GenerationSpanData): + return self._create_generation_span(span_data, parent_ctx) + if isinstance(span_data, ResponseSpanData): + return self._create_response_span(span_data, parent_ctx) + if isinstance(span_data, FunctionSpanData): + return self._create_function_span(span_data, parent_ctx) + if isinstance(span_data, HandoffSpanData): + return self._create_handoff_span(span_data, parent_ctx) + if isinstance(span_data, GuardrailSpanData): + return self._create_guardrail_span(span_data, parent_ctx) + # Fallback for custom/unknown span types + return self._create_generic_span(span_data, parent_ctx) + + def _create_agent_span( + self, data: AgentSpanData, parent_ctx: Any | None + ) -> OTelSpan: + span_name = ( + f"{GenAI.GenAiOperationNameValues.INVOKE_AGENT.value} {data.name}" + ) + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.INTERNAL, + context=parent_ctx, + ) + span.set_attribute( + GenAI.GEN_AI_OPERATION_NAME, + GenAI.GenAiOperationNameValues.INVOKE_AGENT.value, + ) + span.set_attribute(GenAI.GEN_AI_SYSTEM, _PROVIDER_NAME) + span.set_attribute(GenAI.GEN_AI_AGENT_NAME, data.name) + return span + + def _create_generation_span( + self, + data: GenerationSpanData, + parent_ctx: Any | None, + ) -> OTelSpan: + model_name = data.model or "unknown" + span_name = f"chat {model_name}" + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.CLIENT, + context=parent_ctx, + ) + span.set_attribute(GenAI.GEN_AI_OPERATION_NAME, "chat") + span.set_attribute(GenAI.GEN_AI_SYSTEM, "openai") + if data.model: + span.set_attribute(GenAI.GEN_AI_REQUEST_MODEL, data.model) + if data.model_config: + self._set_model_config(span, data.model_config) + return span + + def _create_response_span( + self, + data: ResponseSpanData, + parent_ctx: Any | None, + ) -> OTelSpan: + model_name = "unknown" + if data.response and hasattr(data.response, "model"): + model_name = data.response.model or "unknown" + span_name = f"chat {model_name}" + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.CLIENT, + context=parent_ctx, + ) + span.set_attribute(GenAI.GEN_AI_OPERATION_NAME, "chat") + span.set_attribute(GenAI.GEN_AI_SYSTEM, "openai") + if model_name != "unknown": + span.set_attribute(GenAI.GEN_AI_REQUEST_MODEL, model_name) + return span + + def _create_function_span( + self, + data: FunctionSpanData, + parent_ctx: Any | None, + ) -> OTelSpan: + span_name = ( + f"{GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value} {data.name}" + ) + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.INTERNAL, + context=parent_ctx, + ) + span.set_attribute( + GenAI.GEN_AI_OPERATION_NAME, + GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value, + ) + span.set_attribute(GenAI.GEN_AI_SYSTEM, _PROVIDER_NAME) + span.set_attribute(GenAI.GEN_AI_TOOL_NAME, data.name) + span.set_attribute(GenAI.GEN_AI_TOOL_TYPE, "function") + return span + + def _create_handoff_span( + self, + data: HandoffSpanData, + parent_ctx: Any | None, + ) -> OTelSpan: + from_name = data.from_agent or "unknown" + to_name = data.to_agent or "unknown" + span_name = f"{from_name} -> {to_name}" + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.INTERNAL, + context=parent_ctx, + ) + span.set_attribute(GenAI.GEN_AI_SYSTEM, _PROVIDER_NAME) + span.set_attribute(_ATTR_HANDOFF_FROM, from_name) + span.set_attribute(_ATTR_HANDOFF_TO, to_name) + if data.from_agent: + span.set_attribute(GenAI.GEN_AI_AGENT_NAME, data.from_agent) + return span + + def _create_guardrail_span( + self, + data: GuardrailSpanData, + parent_ctx: Any | None, + ) -> OTelSpan: + span_name = f"guardrail {data.name}" + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.INTERNAL, + context=parent_ctx, + ) + span.set_attribute(GenAI.GEN_AI_SYSTEM, _PROVIDER_NAME) + span.set_attribute(_ATTR_GUARDRAIL_NAME, data.name) + return span + + def _create_generic_span( + self, + span_data: Any, + parent_ctx: Any | None, + ) -> OTelSpan: + span_type = getattr(span_data, "type", "unknown") + span_name = getattr(span_data, "name", span_type) + span = self._handler._tracer.start_span( + name=span_name, + kind=SpanKind.INTERNAL, + context=parent_ctx, + ) + span.set_attribute(GenAI.GEN_AI_SYSTEM, _PROVIDER_NAME) + return span + + # ------------------------------------------------------------------ + # End-of-span attribute population + # ------------------------------------------------------------------ + + def _apply_end_attributes( + self, otel_span: OTelSpan, sdk_span: Span[Any] + ) -> None: + span_data = sdk_span.span_data + if isinstance(span_data, AgentSpanData): + self._apply_agent_end(otel_span, span_data) + elif isinstance(span_data, GenerationSpanData): + self._apply_generation_end(otel_span, span_data) + elif isinstance(span_data, ResponseSpanData): + self._apply_response_end(otel_span, span_data) + elif isinstance(span_data, FunctionSpanData): + self._apply_function_end(otel_span, span_data) + elif isinstance(span_data, HandoffSpanData): + self._apply_handoff_end(otel_span, span_data) + elif isinstance(span_data, GuardrailSpanData): + self._apply_guardrail_end(otel_span, span_data) + + def _apply_agent_end(self, span: OTelSpan, data: AgentSpanData) -> None: + if data.tools: + span.set_attribute( + "gen_ai.openai.agents.agent.tools", + data.tools, + ) + if data.handoffs: + span.set_attribute( + "gen_ai.openai.agents.agent.handoffs", + data.handoffs, + ) + if data.output_type: + span.set_attribute(GenAI.GEN_AI_OUTPUT_TYPE, data.output_type) + + def _apply_generation_end( + self, span: OTelSpan, data: GenerationSpanData + ) -> None: + if data.model: + span.set_attribute(GenAI.GEN_AI_RESPONSE_MODEL, data.model) + if data.usage: + input_tokens = data.usage.get("input_tokens") or data.usage.get( + "prompt_tokens" + ) + output_tokens = data.usage.get("output_tokens") or data.usage.get( + "completion_tokens" + ) + if input_tokens is not None: + span.set_attribute( + GenAI.GEN_AI_USAGE_INPUT_TOKENS, + input_tokens, + ) + if output_tokens is not None: + span.set_attribute( + GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, + output_tokens, + ) + if data.model_config: + self._set_model_config(span, data.model_config) + if self._capture_content: + if data.input: + span.set_attribute( + "gen_ai.input.messages", + _safe_json(list(data.input)), + ) + if data.output: + span.set_attribute( + "gen_ai.output.messages", + _safe_json(list(data.output)), + ) + + def _apply_response_end( + self, span: OTelSpan, data: ResponseSpanData + ) -> None: + resp = data.response + if resp is None: + return + if hasattr(resp, "model") and resp.model: + span.set_attribute(GenAI.GEN_AI_RESPONSE_MODEL, resp.model) + if hasattr(resp, "id") and resp.id: + span.set_attribute(GenAI.GEN_AI_RESPONSE_ID, resp.id) + if hasattr(resp, "usage") and resp.usage: + usage = resp.usage + if hasattr(usage, "input_tokens"): + span.set_attribute( + GenAI.GEN_AI_USAGE_INPUT_TOKENS, + usage.input_tokens, + ) + if hasattr(usage, "output_tokens"): + span.set_attribute( + GenAI.GEN_AI_USAGE_OUTPUT_TOKENS, + usage.output_tokens, + ) + if self._capture_content and data.input: + span.set_attribute( + "gen_ai.input.messages", + _safe_json(data.input), + ) + + def _apply_function_end( + self, span: OTelSpan, data: FunctionSpanData + ) -> None: + if self._capture_content: + if data.input is not None: + span.set_attribute( + GenAI.GEN_AI_TOOL_CALL_ARGUMENTS, + str(data.input), + ) + if data.output is not None: + span.set_attribute( + GenAI.GEN_AI_TOOL_CALL_RESULT, + str(data.output), + ) + if data.mcp_data: + span.set_attribute( + "gen_ai.openai.agents.mcp.server", + _safe_json(data.mcp_data), + ) + + def _apply_handoff_end( + self, span: OTelSpan, data: HandoffSpanData + ) -> None: + if data.to_agent: + span.set_attribute(_ATTR_HANDOFF_TO, data.to_agent) + + def _apply_guardrail_end( + self, span: OTelSpan, data: GuardrailSpanData + ) -> None: + span.set_attribute(_ATTR_GUARDRAIL_TRIGGERED, data.triggered) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + @staticmethod + def _set_model_config(span: OTelSpan, config: Any) -> None: + if not isinstance(config, dict): + return + _CONFIG_ATTRS = { + "temperature": GenAI.GEN_AI_REQUEST_TEMPERATURE, + "max_tokens": GenAI.GEN_AI_REQUEST_MAX_TOKENS, + "top_p": GenAI.GEN_AI_REQUEST_TOP_P, + "top_k": GenAI.GEN_AI_REQUEST_TOP_K, + } + for key, attr in _CONFIG_ATTRS.items(): + val = config.get(key) + if val is not None: + span.set_attribute(attr, val) + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + @_dont_throw + def shutdown(self) -> None: + for otel_span, ctx_token in self._span_map.values(): + otel_span.end() + if ctx_token is not None: + _safe_detach(ctx_token) + for otel_span, ctx_token in self._trace_map.values(): + otel_span.end() + if ctx_token is not None: + _safe_detach(ctx_token) + self._span_map.clear() + self._trace_map.clear() + + @_dont_throw + def force_flush(self) -> None: + pass diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/package.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/package.py new file mode 100644 index 000000000..d8bc7a11f --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/package.py @@ -0,0 +1,17 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +_instruments = ("openai-agents >= 0.0.7",) + +_supports_metrics = False diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/version.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/version.py new file mode 100644 index 000000000..8afceb914 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/version.py @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +__version__ = "0.4.0.dev" diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/__init__.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/__init__.py new file mode 100644 index 000000000..b0a6f4284 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/__init__.py @@ -0,0 +1,13 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/conftest.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/conftest.py new file mode 100644 index 000000000..7f8461ca5 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/conftest.py @@ -0,0 +1,81 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os + +import pytest + +os.environ.setdefault( + "OTEL_SEMCONV_STABILITY_OPT_IN", "gen_ai_latest_experimental" +) +os.environ.setdefault( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", "SPAN_ONLY" +) +os.environ.setdefault("OPENAI_API_KEY", "test-key") + +from opentelemetry.instrumentation.openai_agents import ( + OpenAIAgentsInstrumentor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +@pytest.fixture(name="span_exporter") +def fixture_span_exporter(): + exporter = InMemorySpanExporter() + yield exporter + + +@pytest.fixture(name="tracer_provider") +def fixture_tracer_provider(span_exporter): + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + return provider + + +@pytest.fixture() +def instrument(tracer_provider): + instrumentor = OpenAIAgentsInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + yield instrumentor + instrumentor.uninstrument() + + +@pytest.fixture() +def instrument_no_content(tracer_provider): + old_val = os.environ.get( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" + ) + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + "NO_CONTENT" + ) + + instrumentor = OpenAIAgentsInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + + yield instrumentor + + instrumentor.uninstrument() + if old_val is not None: + os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = ( + old_val + ) + else: + os.environ.pop( + "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT", + None, + ) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/requirements.latest.txt b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/requirements.latest.txt new file mode 100644 index 000000000..49b56e7f1 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/requirements.latest.txt @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +openai-agents diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/requirements.oldest.txt b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/requirements.oldest.txt new file mode 100644 index 000000000..291a4721d --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/requirements.oldest.txt @@ -0,0 +1,15 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +openai-agents==0.0.7 diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/test_instrumentor.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/test_instrumentor.py new file mode 100644 index 000000000..501ca089e --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/test_instrumentor.py @@ -0,0 +1,75 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the OpenAIAgentsInstrumentor lifecycle.""" + +import os + +os.environ.setdefault("OPENAI_API_KEY", "test-key") + +from agents.tracing.span_data import AgentSpanData +from tests.test_processor import FakeSpan, FakeTrace + +from opentelemetry.instrumentation.openai_agents import ( + OpenAIAgentsInstrumentor, +) +from opentelemetry.instrumentation.openai_agents._processor import ( + OTelTracingProcessor, +) + + +class TestInstrumentor: + def test_instrument_registers_processor(self, instrument): + assert OpenAIAgentsInstrumentor._processor is not None + assert isinstance( + OpenAIAgentsInstrumentor._processor, + OTelTracingProcessor, + ) + + def test_uninstrument_clears_state(self, tracer_provider): + instrumentor = OpenAIAgentsInstrumentor() + instrumentor.instrument(tracer_provider=tracer_provider) + assert OpenAIAgentsInstrumentor._processor is not None + + instrumentor.uninstrument() + assert OpenAIAgentsInstrumentor._processor is None + assert OpenAIAgentsInstrumentor._handler is None + + def test_end_to_end_with_instrumentor(self, instrument, span_exporter): + """Simulate an agent run via the processor + registered by the instrumentor.""" + processor = OpenAIAgentsInstrumentor._processor + + trace = FakeTrace(name="E2E Test") + processor.on_trace_start(trace) + + agent_data = AgentSpanData(name="test_agent") + sdk_span = FakeSpan( + agent_data, + span_id="e2e_agent_001", + trace_id=trace.trace_id, + ) + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = span_exporter.get_finished_spans() + span_names = [s.name for s in spans] + assert "invoke_workflow E2E Test" in span_names + assert "invoke_agent test_agent" in span_names + + def test_dependencies(self): + instrumentor = OpenAIAgentsInstrumentor() + deps = instrumentor.instrumentation_dependencies() + assert any("openai-agents" in d for d in deps) diff --git a/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/test_processor.py b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/test_processor.py new file mode 100644 index 000000000..87a3d6bc0 --- /dev/null +++ b/instrumentation-loongsuite/loongsuite-instrumentation-openai-agents/tests/test_processor.py @@ -0,0 +1,471 @@ +# Copyright The OpenTelemetry Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for OTelTracingProcessor spanning all supported SDK span types.""" + +import os + +import pytest + +os.environ.setdefault("OPENAI_API_KEY", "test-key") + +from agents.tracing.span_data import ( + AgentSpanData, + FunctionSpanData, + GenerationSpanData, + GuardrailSpanData, + HandoffSpanData, + ResponseSpanData, +) + +from opentelemetry.instrumentation.openai_agents._processor import ( + OTelTracingProcessor, +) +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.util.genai.extended_handler import ( + ExtendedTelemetryHandler, +) + +# ----------------------------------------------------------- +# Helpers: lightweight fakes for the SDK's Trace / Span ABCs +# ----------------------------------------------------------- + + +class FakeTrace: + def __init__(self, trace_id="trace_001", name="Test Workflow"): + self.trace_id = trace_id + self.name = name + + +class FakeSpan: + def __init__( + self, + span_data, + span_id="span_001", + trace_id="trace_001", + parent_id=None, + error=None, + ): + self._span_data = span_data + self._span_id = span_id + self._trace_id = trace_id + self._parent_id = parent_id + self._error = error + + @property + def span_data(self): + return self._span_data + + @property + def span_id(self): + return self._span_id + + @property + def trace_id(self): + return self._trace_id + + @property + def parent_id(self): + return self._parent_id + + @property + def error(self): + return self._error + + +# ----------------------------------------------------------- +# Fixtures +# ----------------------------------------------------------- + + +@pytest.fixture() +def setup(): + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + handler = ExtendedTelemetryHandler(tracer_provider=provider) + processor = OTelTracingProcessor(handler=handler, capture_content=True) + return processor, exporter + + +# ----------------------------------------------------------- +# Tests +# ----------------------------------------------------------- + + +class TestTraceLifecycle: + def test_trace_creates_workflow_span(self, setup): + processor, exporter = setup + trace = FakeTrace(name="My Workflow") + + processor.on_trace_start(trace) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.name == "invoke_workflow My Workflow" + attrs = dict(span.attributes) + assert attrs["gen_ai.operation.name"] == "invoke_workflow" + assert attrs["gen_ai.system"] == "openai_agents" + + +class TestAgentSpan: + def test_agent_span_attributes(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + agent_data = AgentSpanData( + name="weather_agent", + tools=["get_weather", "get_forecast"], + handoffs=["travel_agent"], + output_type="str", + ) + sdk_span = FakeSpan(agent_data, span_id="agent_001") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + agent_spans = [ + s for s in spans if s.name == "invoke_agent weather_agent" + ] + assert len(agent_spans) == 1 + attrs = dict(agent_spans[0].attributes) + assert attrs["gen_ai.operation.name"] == "invoke_agent" + assert attrs["gen_ai.agent.name"] == "weather_agent" + assert attrs["gen_ai.output.type"] == "str" + assert list(attrs["gen_ai.openai.agents.agent.tools"]) == [ + "get_weather", + "get_forecast", + ] + assert list(attrs["gen_ai.openai.agents.agent.handoffs"]) == [ + "travel_agent" + ] + + +class TestGenerationSpan: + def test_generation_span_with_usage(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + gen_data = GenerationSpanData( + model="gpt-4o", + model_config={"temperature": 0.7, "max_tokens": 1024}, + usage={ + "input_tokens": 100, + "output_tokens": 50, + }, + input=[{"role": "user", "content": "Hello"}], + output=[{"role": "assistant", "content": "Hi there!"}], + ) + sdk_span = FakeSpan(gen_data, span_id="gen_001") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + gen_spans = [s for s in spans if s.name == "chat gpt-4o"] + assert len(gen_spans) == 1 + attrs = dict(gen_spans[0].attributes) + assert attrs["gen_ai.operation.name"] == "chat" + assert attrs["gen_ai.system"] == "openai" + assert attrs["gen_ai.request.model"] == "gpt-4o" + assert attrs["gen_ai.response.model"] == "gpt-4o" + assert attrs["gen_ai.usage.input_tokens"] == 100 + assert attrs["gen_ai.usage.output_tokens"] == 50 + assert attrs["gen_ai.request.temperature"] == 0.7 + assert attrs["gen_ai.request.max_tokens"] == 1024 + assert "Hello" in attrs["gen_ai.input.messages"] + assert "Hi there!" in attrs["gen_ai.output.messages"] + + def test_generation_span_no_content(self, setup): + processor, exporter = setup + processor._capture_content = False + trace = FakeTrace() + processor.on_trace_start(trace) + + gen_data = GenerationSpanData( + model="gpt-4o", + input=[{"role": "user", "content": "secret"}], + output=[{"role": "assistant", "content": "classified"}], + ) + sdk_span = FakeSpan(gen_data, span_id="gen_002") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + gen_spans = [s for s in spans if s.name == "chat gpt-4o"] + attrs = dict(gen_spans[0].attributes) + assert "gen_ai.input.messages" not in attrs + assert "gen_ai.output.messages" not in attrs + + +class TestFunctionSpan: + def test_tool_execution_span(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + func_data = FunctionSpanData( + name="get_weather", + input='{"city": "Tokyo"}', + output='{"temp": 22}', + ) + sdk_span = FakeSpan(func_data, span_id="func_001") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + tool_spans = [s for s in spans if s.name == "execute_tool get_weather"] + assert len(tool_spans) == 1 + attrs = dict(tool_spans[0].attributes) + assert attrs["gen_ai.operation.name"] == "execute_tool" + assert attrs["gen_ai.tool.name"] == "get_weather" + assert attrs["gen_ai.tool.type"] == "function" + assert "Tokyo" in attrs["gen_ai.tool.call.arguments"] + assert "22" in attrs["gen_ai.tool.call.result"] + + +class TestHandoffSpan: + def test_handoff_attributes(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + handoff_data = HandoffSpanData( + from_agent="triage", to_agent="specialist" + ) + sdk_span = FakeSpan(handoff_data, span_id="handoff_001") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + handoff_spans = [s for s in spans if s.name == "triage -> specialist"] + assert len(handoff_spans) == 1 + attrs = dict(handoff_spans[0].attributes) + assert attrs["gen_ai.openai.agents.handoff.from_agent"] == "triage" + assert attrs["gen_ai.openai.agents.handoff.to_agent"] == "specialist" + + +class TestGuardrailSpan: + def test_guardrail_triggered(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + guard_data = GuardrailSpanData(name="content_filter", triggered=True) + sdk_span = FakeSpan(guard_data, span_id="guard_001") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + guard_spans = [ + s for s in spans if s.name == "guardrail content_filter" + ] + assert len(guard_spans) == 1 + attrs = dict(guard_spans[0].attributes) + assert attrs["gen_ai.openai.agents.guardrail.name"] == "content_filter" + assert attrs["gen_ai.openai.agents.guardrail.triggered"] is True + + def test_guardrail_not_triggered(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + guard_data = GuardrailSpanData(name="safety_check", triggered=False) + sdk_span = FakeSpan(guard_data, span_id="guard_002") + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + guard_spans = [s for s in spans if s.name == "guardrail safety_check"] + attrs = dict(guard_spans[0].attributes) + assert attrs["gen_ai.openai.agents.guardrail.triggered"] is False + + +class TestSpanHierarchy: + def test_nested_agent_tool_spans(self, setup): + processor, exporter = setup + + trace = FakeTrace() + processor.on_trace_start(trace) + + agent_data = AgentSpanData(name="assistant") + agent_span = FakeSpan(agent_data, span_id="agent_001") + processor.on_span_start(agent_span) + + func_data = FunctionSpanData( + name="search", input="query", output="result" + ) + func_span = FakeSpan( + func_data, + span_id="func_001", + parent_id="agent_001", + ) + processor.on_span_start(func_span) + processor.on_span_end(func_span) + + processor.on_span_end(agent_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + assert len(spans) == 3 + + tool_span = next(s for s in spans if s.name == "execute_tool search") + otel_agent_span = next( + s for s in spans if s.name == "invoke_agent assistant" + ) + assert tool_span.parent.span_id == otel_agent_span.context.span_id + + +class TestErrorHandling: + def test_span_error_sets_status(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + agent_data = AgentSpanData(name="failing_agent") + sdk_span = FakeSpan( + agent_data, + span_id="err_001", + error={ + "message": "Tool execution failed", + "data": None, + }, + ) + + processor.on_span_start(sdk_span) + processor.on_span_end(sdk_span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + agent_spans = [ + s for s in spans if s.name == "invoke_agent failing_agent" + ] + assert len(agent_spans) == 1 + assert agent_spans[0].status.is_ok is False + + def test_processor_does_not_throw(self, setup): + processor, _ = setup + processor.on_span_start(None) + processor.on_span_end(None) + processor.on_trace_start(None) + processor.on_trace_end(None) + + +class _FakeUsage: + def __init__(self, input_tokens=100, output_tokens=50): + self.input_tokens = input_tokens + self.output_tokens = output_tokens + + +class _FakeResponse: + def __init__(self, model="gpt-4o", resp_id="resp-001", usage=None): + self.model = model + self.id = resp_id + self.usage = usage or _FakeUsage() + + +class TestResponseSpan: + def test_response_creates_chat_span(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + data = ResponseSpanData(response=_FakeResponse()) + span = FakeSpan( + span_data=data, + span_id="resp_span_001", + trace_id=trace.trace_id, + ) + processor.on_span_start(span) + processor.on_span_end(span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + chat_spans = [s for s in spans if "chat" in s.name] + assert len(chat_spans) == 1 + attrs = dict(chat_spans[0].attributes) + assert attrs["gen_ai.operation.name"] == "chat" + assert attrs["gen_ai.system"] == "openai" + assert attrs["gen_ai.request.model"] == "gpt-4o" + + def test_response_extracts_usage(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + resp = _FakeResponse( + model="gpt-4o-mini", + usage=_FakeUsage(input_tokens=200, output_tokens=80), + ) + data = ResponseSpanData(response=resp) + span = FakeSpan( + span_data=data, + span_id="resp_span_002", + trace_id=trace.trace_id, + ) + processor.on_span_start(span) + processor.on_span_end(span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + chat_spans = [s for s in spans if "chat" in s.name] + assert len(chat_spans) == 1 + attrs = dict(chat_spans[0].attributes) + assert attrs["gen_ai.response.model"] == "gpt-4o-mini" + assert attrs["gen_ai.response.id"] == "resp-001" + assert attrs["gen_ai.usage.input_tokens"] == 200 + assert attrs["gen_ai.usage.output_tokens"] == 80 + + def test_response_captures_input_content(self, setup): + processor, exporter = setup + trace = FakeTrace() + processor.on_trace_start(trace) + + data = ResponseSpanData(response=_FakeResponse()) + data.input = [{"role": "user", "content": "Hello"}] + span = FakeSpan( + span_data=data, + span_id="resp_span_003", + trace_id=trace.trace_id, + ) + processor.on_span_start(span) + processor.on_span_end(span) + processor.on_trace_end(trace) + + spans = exporter.get_finished_spans() + chat_spans = [s for s in spans if "chat" in s.name] + assert len(chat_spans) == 1 + attrs = dict(chat_spans[0].attributes) + assert "gen_ai.input.messages" in attrs