From 4830f5928864184d9fc79c9e6647e92a9506d475 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Tue, 7 Oct 2025 13:02:39 -0700 Subject: [PATCH 01/18] Implement OpenAI Agents span processing --- .../instrumentation/openai_agents/__init__.py | 89 +++- .../openai_agents/span_processor.py | 422 ++++++++++++++++++ .../tests/conftest.py | 20 + .../tests/stubs/agents/__init__.py | 1 + .../tests/stubs/agents/tracing/__init__.py | 187 ++++++++ .../agents/tracing/processor_interface.py | 21 + .../tests/stubs/agents/tracing/spans.py | 42 ++ .../tests/stubs/agents/tracing/traces.py | 31 ++ .../instrumentation/instrumentor.py | 26 ++ .../tests/test_tracer.py | 110 +++++ 10 files changed, 935 insertions(+), 14 deletions(-) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/conftest.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/processor_interface.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/spans.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/traces.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/opentelemetry/instrumentation/instrumentor.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py index 6cf9599b64..56861774c2 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py @@ -12,34 +12,95 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Barebones OpenAI Agents instrumentation package. +"""OpenAI Agents instrumentation for OpenTelemetry.""" -This branch provides only the minimal package skeleton: -- Instrumentor class stub -- Version module -- Packaging metadata/entry point -""" +from __future__ import annotations +import importlib +import os from typing import Collection from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) +from opentelemetry.semconv.schemas import Schemas +from opentelemetry.trace import get_tracer from .package import _instruments +from .span_processor import _OpenAIAgentsSpanProcessor from .version import __version__ # noqa: F401 -__all__ = [ - "OpenAIAgentsInstrumentor", -] +__all__ = ["OpenAIAgentsInstrumentor"] + + +def _load_tracing_module(): + return importlib.import_module("agents.tracing") + + +def _resolve_system(value: str | None) -> str: + if not value: + return GenAI.GenAiSystemValues.OPENAI.value + + normalized = value.strip().lower() + for member in GenAI.GenAiSystemValues: + if normalized == member.value: + return member.value + if normalized == member.name.lower(): + return member.value + return value + + +def _get_registered_processors(provider) -> list: + multi = getattr(provider, "_multi_processor", None) + processors = getattr(multi, "_processors", ()) + return list(processors) class OpenAIAgentsInstrumentor(BaseInstrumentor): - """Minimal instrumentor stub (no-op).""" + """Instrumentation that bridges OpenAI Agents tracing to OpenTelemetry spans.""" + + def __init__(self) -> None: + super().__init__() + self._processor: _OpenAIAgentsSpanProcessor | None = None + + def _instrument(self, **kwargs) -> None: + if self._processor is not None: + return + + tracer_provider = kwargs.get("tracer_provider") + tracer = get_tracer( + __name__, + "", + tracer_provider, + schema_url=Schemas.V1_28_0.value, + ) + + system_override = kwargs.get("system") or os.getenv( + "OTEL_INSTRUMENTATION_OPENAI_AGENTS_SYSTEM" + ) + system = _resolve_system(system_override) + + processor = _OpenAIAgentsSpanProcessor(tracer=tracer, system=system) + + tracing = _load_tracing_module() + provider = tracing.get_trace_provider() + existing = _get_registered_processors(provider) + provider.set_processors([*existing, processor]) + self._processor = processor + + def _uninstrument(self, **kwargs) -> None: + if self._processor is None: + return - def _instrument(self, **kwargs) -> None: # pragma: no cover - stub - return + tracing = _load_tracing_module() + provider = tracing.get_trace_provider() + current = _get_registered_processors(provider) + filtered = [proc for proc in current if proc is not self._processor] + provider.set_processors(filtered) - def _uninstrument(self, **kwargs) -> None: # pragma: no cover - stub - return + self._processor.shutdown() + self._processor = None def instrumentation_dependencies(self) -> Collection[str]: return _instruments diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py new file mode 100644 index 0000000000..872bf0f8ad --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -0,0 +1,422 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from datetime import datetime +from threading import RLock +from typing import Any +from urllib.parse import urlparse + +try: # pragma: no cover - used when OpenAI Agents is available + from agents.tracing.processor_interface import TracingProcessor + from agents.tracing.spans import Span as AgentsSpan + from agents.tracing.traces import Trace as AgentsTrace +except ImportError: # pragma: no cover - fallback for tests + + class TracingProcessor: # type: ignore[misc] + pass + + AgentsSpan = Any # type: ignore[assignment] + AgentsTrace = Any # type: ignore[assignment] +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAI, +) +from opentelemetry.semconv._incubating.attributes import ( + server_attributes as ServerAttributes, +) +from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context +from opentelemetry.trace.status import Status, StatusCode + + +def _parse_iso8601(timestamp: str | None) -> float | None: + """Return POSIX timestamp (seconds) for ISO8601 string.""" + + if not timestamp: + return None + + try: + if timestamp.endswith("Z"): + timestamp = timestamp[:-1] + "+00:00" + dt = datetime.fromisoformat(timestamp) + except ValueError: + return None + + return dt.timestamp() + + +def _extract_server_attributes( + config: Mapping[str, Any] | None, +) -> dict[str, Any]: + if not config: + return {} + + base_url = config.get("base_url") + if not isinstance(base_url, str): + return {} + + try: + parsed = urlparse(base_url) + except ValueError: + return {} + + attributes: dict[str, Any] = {} + if parsed.hostname: + attributes[ServerAttributes.SERVER_ADDRESS] = parsed.hostname + if parsed.port: + attributes[ServerAttributes.SERVER_PORT] = parsed.port + + return attributes + + +def _looks_like_chat(messages: Sequence[Mapping[str, Any]] | None) -> bool: + if not messages: + return False + for message in messages: + if isinstance(message, Mapping) and message.get("role"): + return True + return False + + +def _collect_finish_reasons(choices: Sequence[Any] | None) -> list[str]: + reasons: list[str] = [] + if not choices: + return reasons + + for choice in choices: + if isinstance(choice, Mapping): + reason = choice.get("finish_reason") or choice.get("stop_reason") + if reason: + reasons.append(str(reason)) + continue + + finish_reason = getattr(choice, "finish_reason", None) + if finish_reason: + reasons.append(str(finish_reason)) + + return reasons + + +def _clean_stop_sequences(value: Any) -> Sequence[str] | None: + if value is None: + return None + if isinstance(value, str): + return [value] + if isinstance(value, Sequence): + cleaned: list[str] = [] + for item in value: + if item is None: + continue + cleaned.append(str(item)) + return cleaned if cleaned else None + return None + + +@dataclass +class _SpanContext: + span: Span + kind: SpanKind + + +class _OpenAIAgentsSpanProcessor(TracingProcessor): + """Convert OpenAI Agents traces into OpenTelemetry spans.""" + + def __init__(self, tracer: Tracer, system: str) -> None: + self._tracer = tracer + self._system = system + self._root_spans: dict[str, Span] = {} + self._spans: dict[str, _SpanContext] = {} + self._lock = RLock() + + def _operation_name(self, span_data: Any) -> str: + span_type = getattr(span_data, "type", None) + if span_type == "generation": + if _looks_like_chat(getattr(span_data, "input", None)): + return GenAI.GenAiOperationNameValues.CHAT.value + return GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value + if span_type == "agent": + return GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + if span_type == "function": + return GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value + if span_type == "response": + return GenAI.GenAiOperationNameValues.CHAT.value + return span_type or "operation" + + def _span_kind(self, span_data: Any) -> SpanKind: + span_type = getattr(span_data, "type", None) + if span_type in {"generation", "response", "speech", "transcription"}: + return SpanKind.CLIENT + return SpanKind.INTERNAL + + def _span_name(self, operation: str, attributes: Mapping[str, Any]) -> str: + model = attributes.get(GenAI.GEN_AI_REQUEST_MODEL) + agent_name = attributes.get(GenAI.GEN_AI_AGENT_NAME) + tool_name = attributes.get(GenAI.GEN_AI_TOOL_NAME) + + if operation in ( + GenAI.GenAiOperationNameValues.CHAT.value, + GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value, + GenAI.GenAiOperationNameValues.GENERATE_CONTENT.value, + GenAI.GenAiOperationNameValues.EMBEDDINGS.value, + ): + return f"{operation} {model}" if model else operation + if operation == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value: + return f"{operation} {agent_name}" if agent_name else operation + if operation == GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value: + return f"{operation} {tool_name}" if tool_name else operation + if operation == GenAI.GenAiOperationNameValues.CREATE_AGENT.value: + return f"{operation} {agent_name}" if agent_name else operation + return operation + + def _base_attributes(self) -> dict[str, Any]: + return {GenAI.GEN_AI_SYSTEM: self._system} + + def _attributes_from_generation(self, span_data: Any) -> dict[str, Any]: + attributes = self._base_attributes() + attributes[GenAI.GEN_AI_OPERATION_NAME] = self._operation_name( + span_data + ) + + model = getattr(span_data, "model", None) + if model: + attributes[GenAI.GEN_AI_REQUEST_MODEL] = model + + attributes.update( + _extract_server_attributes( + getattr(span_data, "model_config", None) + ) + ) + + usage = getattr(span_data, "usage", None) + if isinstance(usage, Mapping): + input_tokens = usage.get("prompt_tokens") or usage.get( + "input_tokens" + ) + if input_tokens is not None: + attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] = input_tokens + output_tokens = usage.get("completion_tokens") or usage.get( + "output_tokens" + ) + if output_tokens is not None: + attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = output_tokens + + model_config = getattr(span_data, "model_config", None) + if isinstance(model_config, Mapping): + mapping = { + "temperature": GenAI.GEN_AI_REQUEST_TEMPERATURE, + "top_p": GenAI.GEN_AI_REQUEST_TOP_P, + "top_k": GenAI.GEN_AI_REQUEST_TOP_K, + "frequency_penalty": GenAI.GEN_AI_REQUEST_FREQUENCY_PENALTY, + "presence_penalty": GenAI.GEN_AI_REQUEST_PRESENCE_PENALTY, + "seed": GenAI.GEN_AI_REQUEST_SEED, + "n": GenAI.GEN_AI_REQUEST_CHOICE_COUNT, + } + for key, attr in mapping.items(): + value = model_config.get(key) + if value is not None: + attributes[attr] = value + + for max_key in ("max_tokens", "max_completion_tokens"): + value = model_config.get(max_key) + if value is not None: + attributes[GenAI.GEN_AI_REQUEST_MAX_TOKENS] = value + break + + stop_sequences = _clean_stop_sequences(model_config.get("stop")) + if stop_sequences: + attributes[GenAI.GEN_AI_REQUEST_STOP_SEQUENCES] = ( + stop_sequences + ) + + attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] = ( + _collect_finish_reasons(getattr(span_data, "output", None)) + ) + + return attributes + + def _attributes_from_response(self, span_data: Any) -> dict[str, Any]: + attributes = self._base_attributes() + attributes[GenAI.GEN_AI_OPERATION_NAME] = self._operation_name( + span_data + ) + + response = getattr(span_data, "response", None) + if response is None: + return attributes + + response_id = getattr(response, "id", None) + if response_id is not None: + attributes[GenAI.GEN_AI_RESPONSE_ID] = response_id + + response_model = getattr(response, "model", None) + if response_model: + attributes[GenAI.GEN_AI_RESPONSE_MODEL] = response_model + + usage = getattr(response, "usage", None) + if usage is not None: + input_tokens = getattr(usage, "input_tokens", None) or getattr( + usage, "prompt_tokens", None + ) + if input_tokens is not None: + attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] = input_tokens + output_tokens = getattr(usage, "output_tokens", None) or getattr( + usage, "completion_tokens", None + ) + if output_tokens is not None: + attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] = output_tokens + + output = getattr(response, "output", None) + if output: + attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] = ( + _collect_finish_reasons(output) + ) + + return attributes + + def _attributes_from_agent(self, span_data: Any) -> dict[str, Any]: + attributes = self._base_attributes() + attributes[GenAI.GEN_AI_OPERATION_NAME] = ( + GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + ) + + name = getattr(span_data, "name", None) + if name: + attributes[GenAI.GEN_AI_AGENT_NAME] = name + output_type = getattr(span_data, "output_type", None) + if output_type: + attributes[GenAI.GEN_AI_OUTPUT_TYPE] = output_type + + return attributes + + def _attributes_from_function(self, span_data: Any) -> dict[str, Any]: + attributes = self._base_attributes() + attributes[GenAI.GEN_AI_OPERATION_NAME] = ( + GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value + ) + + name = getattr(span_data, "name", None) + if name: + attributes[GenAI.GEN_AI_TOOL_NAME] = name + attributes[GenAI.GEN_AI_TOOL_TYPE] = "function" + + return attributes + + def _attributes_from_generic(self, span_data: Any) -> dict[str, Any]: + attributes = self._base_attributes() + attributes[GenAI.GEN_AI_OPERATION_NAME] = self._operation_name( + span_data + ) + return attributes + + def _attributes_for_span(self, span_data: Any) -> dict[str, Any]: + span_type = getattr(span_data, "type", None) + if span_type == "generation": + return self._attributes_from_generation(span_data) + if span_type == "response": + return self._attributes_from_response(span_data) + if span_type == "agent": + return self._attributes_from_agent(span_data) + if span_type == "function": + return self._attributes_from_function(span_data) + if span_type in { + "guardrail", + "handoff", + "speech_group", + "speech", + "transcription", + "mcp_tools", + }: + return self._attributes_from_generic(span_data) + return self._base_attributes() + + def on_trace_start(self, trace: AgentsTrace) -> None: + attributes = self._base_attributes() + start_time = _parse_iso8601(getattr(trace, "started_at", None)) + + with self._lock: + span = self._tracer.start_span( + name=trace.name, + kind=SpanKind.SERVER, + attributes=attributes, + start_time=start_time, + ) + self._root_spans[trace.trace_id] = span + + def on_trace_end(self, trace: AgentsTrace) -> None: + end_time = _parse_iso8601(getattr(trace, "ended_at", None)) + + with self._lock: + span = self._root_spans.pop(trace.trace_id, None) + + if span: + span.end(end_time=end_time) + + def on_span_start(self, span: AgentsSpan[Any]) -> None: + span_data = span.span_data + start_time = _parse_iso8601(span.started_at) + attributes = self._attributes_for_span(span_data) + operation = attributes.get(GenAI.GEN_AI_OPERATION_NAME, "operation") + name = self._span_name(operation, attributes) + kind = self._span_kind(span_data) + + with self._lock: + parent_span = None + if span.parent_id and span.parent_id in self._spans: + parent_span = self._spans[span.parent_id].span + elif span.trace_id in self._root_spans: + parent_span = self._root_spans[span.trace_id] + + context = set_span_in_context(parent_span) if parent_span else None + otel_span = self._tracer.start_span( + name=name, + kind=kind, + attributes=attributes, + start_time=start_time, + context=context, + ) + self._spans[span.span_id] = _SpanContext(span=otel_span, kind=kind) + + def on_span_end(self, span: AgentsSpan[Any]) -> None: + end_time = _parse_iso8601(span.ended_at) + + with self._lock: + context = self._spans.pop(span.span_id, None) + + if context is None: + return + + otel_span = context.span + if otel_span.is_recording(): + attributes = self._attributes_for_span(span.span_data) + for key, value in attributes.items(): + otel_span.set_attribute(key, value) + + error = span.error + if error: + description = error.get("message") or "" + otel_span.set_status(Status(StatusCode.ERROR, description)) + else: + otel_span.set_status(Status(StatusCode.OK)) + + otel_span.end(end_time=end_time) + + def shutdown(self) -> None: + with self._lock: + spans = list(self._spans.values()) + self._spans.clear() + roots = list(self._root_spans.values()) + self._root_spans.clear() + + for context in spans: + context.span.set_status(Status(StatusCode.ERROR, "shutdown")) + context.span.end() + + for root in roots: + root.set_status(Status(StatusCode.ERROR, "shutdown")) + root.end() + + def force_flush(self) -> None: + # no batching + return + + +__all__ = ["_OpenAIAgentsSpanProcessor"] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/conftest.py new file mode 100644 index 0000000000..f652ad6ddc --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/conftest.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +TESTS_ROOT = Path(__file__).resolve().parent +GENAI_ROOT = TESTS_ROOT.parent +REPO_ROOT = GENAI_ROOT.parent +PROJECT_ROOT = REPO_ROOT.parent + +for path in ( + PROJECT_ROOT / "opentelemetry-instrumentation" / "src", + GENAI_ROOT / "src", + REPO_ROOT / "openai_agents_lib", + REPO_ROOT / "openai_lib", + TESTS_ROOT / "stubs", +): + path_str = str(path) + if path_str not in sys.path: + sys.path.insert(0, path_str) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/__init__.py new file mode 100644 index 0000000000..7804f9c08e --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/__init__.py @@ -0,0 +1 @@ +# Stub package for tests diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py new file mode 100644 index 0000000000..d200b6d1d2 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +from itertools import count +from typing import Any, Mapping, Sequence + +from .processor_interface import TracingProcessor +from .spans import Span +from .traces import Trace + +__all__ = [ + "TraceProvider", + "get_trace_provider", + "set_trace_processors", + "trace", + "generation_span", + "function_span", + "AgentSpanData", + "GenerationSpanData", + "FunctionSpanData", +] + + +@dataclass +class AgentSpanData: + name: str | None = None + tools: list[str] | None = None + output_type: str | None = None + + @property + def type(self) -> str: + return "agent" + + +@dataclass +class FunctionSpanData: + name: str | None = None + input: Any = None + output: Any = None + + @property + def type(self) -> str: + return "function" + + +@dataclass +class GenerationSpanData: + input: Sequence[Mapping[str, Any]] | None = None + output: Sequence[Mapping[str, Any]] | None = None + model: str | None = None + model_config: Mapping[str, Any] | None = None + usage: Mapping[str, Any] | None = None + + @property + def type(self) -> str: + return "generation" + + +class _ProcessorFanout(TracingProcessor): + def __init__(self) -> None: + self._processors: list[TracingProcessor] = [] + + def add_tracing_processor(self, processor: TracingProcessor) -> None: + self._processors.append(processor) + + def set_processors(self, processors: list[TracingProcessor]) -> None: + self._processors = list(processors) + + def on_trace_start(self, trace: Trace) -> None: + for processor in list(self._processors): + processor.on_trace_start(trace) + + def on_trace_end(self, trace: Trace) -> None: + for processor in list(self._processors): + processor.on_trace_end(trace) + + def on_span_start(self, span: Span) -> None: + for processor in list(self._processors): + processor.on_span_start(span) + + def on_span_end(self, span: Span) -> None: + for processor in list(self._processors): + processor.on_span_end(span) + + def shutdown(self) -> None: + for processor in list(self._processors): + processor.shutdown() + + def force_flush(self) -> None: + for processor in list(self._processors): + processor.force_flush() + + +class TraceProvider: + def __init__(self) -> None: + self._multi_processor = _ProcessorFanout() + self._ids = count(1) + + def register_processor(self, processor: TracingProcessor) -> None: + self._multi_processor.add_tracing_processor(processor) + + def set_processors(self, processors: list[TracingProcessor]) -> None: + self._multi_processor.set_processors(processors) + + def create_trace( + self, + name: str, + trace_id: str | None = None, + group_id: str | None = None, + metadata: Mapping[str, Any] | None = None, + disabled: bool = False, + ) -> Trace: + trace_id = trace_id or f"trace_{next(self._ids)}" + return Trace(name, trace_id, self._multi_processor) + + def create_span( + self, + span_data: Any, + span_id: str | None = None, + parent: Trace | Span | None = None, + disabled: bool = False, + ) -> Span: + span_id = span_id or f"span_{next(self._ids)}" + if isinstance(parent, Span): + trace_id = parent.trace_id + parent_id = parent.span_id + elif isinstance(parent, Trace): + trace_id = parent.trace_id + parent_id = None + else: + trace_id = f"trace_{next(self._ids)}" + parent_id = None + return Span( + trace_id, span_id, span_data, parent_id, self._multi_processor + ) + + def shutdown(self) -> None: + self._multi_processor.shutdown() + + +_PROVIDER = TraceProvider() +_CURRENT_TRACE: Trace | None = None + + +def get_trace_provider() -> TraceProvider: + return _PROVIDER + + +def set_trace_processors(processors: list[TracingProcessor]) -> None: + _PROVIDER.set_processors(processors) + + +@contextmanager +def trace(name: str, **kwargs: Any): + global _CURRENT_TRACE + trace_obj = _PROVIDER.create_trace(name, **kwargs) + previous = _CURRENT_TRACE + _CURRENT_TRACE = trace_obj + trace_obj.start() + try: + yield trace_obj + finally: + trace_obj.finish() + _CURRENT_TRACE = previous + + +@contextmanager +def generation_span(**kwargs: Any): + data = GenerationSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() + + +@contextmanager +def function_span(**kwargs: Any): + data = FunctionSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/processor_interface.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/processor_interface.py new file mode 100644 index 0000000000..46455551b6 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/processor_interface.py @@ -0,0 +1,21 @@ +from __future__ import annotations + + +class TracingProcessor: + def on_trace_start(self, trace): # pragma: no cover - stub + pass + + def on_trace_end(self, trace): # pragma: no cover - stub + pass + + def on_span_start(self, span): # pragma: no cover - stub + pass + + def on_span_end(self, span): # pragma: no cover - stub + pass + + def shutdown(self) -> None: # pragma: no cover - stub + pass + + def force_flush(self) -> None: # pragma: no cover - stub + pass diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/spans.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/spans.py new file mode 100644 index 0000000000..5b7335a650 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/spans.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + + +class Span: + def __init__( + self, + trace_id: str, + span_id: str, + span_data: Any, + parent_id: str | None, + processor, + ) -> None: + self.trace_id = trace_id + self.span_id = span_id + self.span_data = span_data + self.parent_id = parent_id + self.started_at: str | None = None + self.ended_at: str | None = None + self.error = None + self._processor = processor + + def start(self) -> None: + if self.started_at is not None: + return + self.started_at = datetime.utcnow().isoformat() + "Z" + self._processor.on_span_start(self) + + def finish(self) -> None: + if self.ended_at is not None: + return + self.ended_at = datetime.utcnow().isoformat() + "Z" + self._processor.on_span_end(self) + + def __enter__(self) -> "Span": + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.finish() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/traces.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/traces.py new file mode 100644 index 0000000000..895c0e3e76 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/traces.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from datetime import datetime + + +class Trace: + def __init__(self, name: str, trace_id: str, processor) -> None: + self.name = name + self.trace_id = trace_id + self._processor = processor + self.started_at: str | None = None + self.ended_at: str | None = None + + def start(self) -> None: + if self.started_at is not None: + return + self.started_at = datetime.utcnow().isoformat() + "Z" + self._processor.on_trace_start(self) + + def finish(self) -> None: + if self.ended_at is not None: + return + self.ended_at = datetime.utcnow().isoformat() + "Z" + self._processor.on_trace_end(self) + + def __enter__(self) -> "Trace": + self.start() + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.finish() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/opentelemetry/instrumentation/instrumentor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/opentelemetry/instrumentation/instrumentor.py new file mode 100644 index 0000000000..c64d31de90 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/opentelemetry/instrumentation/instrumentor.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from typing import Collection + + +class BaseInstrumentor: + def __init__(self) -> None: + pass + + def instrument(self, **kwargs) -> None: + self._instrument(**kwargs) + + def uninstrument(self, **kwargs) -> None: + self._uninstrument(**kwargs) + + # Subclasses override + def _instrument(self, **kwargs) -> None: # pragma: no cover - stub + raise NotImplementedError + + def _uninstrument(self, **kwargs) -> None: # pragma: no cover - stub + raise NotImplementedError + + def instrumentation_dependencies( + self, + ) -> Collection[str]: # pragma: no cover + return [] diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py new file mode 100644 index 0000000000..f80d897132 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import sys +from pathlib import Path + +TESTS_ROOT = Path(__file__).resolve().parent +stub_path = TESTS_ROOT / "stubs" +if str(stub_path) not in sys.path: + sys.path.insert(0, str(stub_path)) + +sys.modules.pop("agents", None) +sys.modules.pop("agents.tracing", None) + +from agents.tracing import ( # noqa: E402 + function_span, + generation_span, + set_trace_processors, + trace, +) + +from opentelemetry.instrumentation.openai_agents import ( # noqa: E402 + OpenAIAgentsInstrumentor, +) +from opentelemetry.sdk.trace import TracerProvider # noqa: E402 +from opentelemetry.sdk.trace.export import ( # noqa: E402 + InMemorySpanExporter, + SimpleSpanProcessor, +) +from opentelemetry.semconv._incubating.attributes import ( # noqa: E402 + gen_ai_attributes as GenAI, +) +from opentelemetry.semconv._incubating.attributes import ( # noqa: E402 + server_attributes as ServerAttributes, +) +from opentelemetry.trace import SpanKind # noqa: E402 + + +def _instrument_with_provider(): + set_trace_processors([]) + provider = TracerProvider() + exporter = InMemorySpanExporter() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + + instrumentor = OpenAIAgentsInstrumentor() + instrumentor.instrument(tracer_provider=provider) + + return instrumentor, exporter + + +def test_generation_span_creates_client_span(): + instrumentor, exporter = _instrument_with_provider() + + try: + with trace("workflow"): + with generation_span( + input=[{"role": "user", "content": "hi"}], + model="gpt-4o-mini", + model_config={ + "temperature": 0.2, + "base_url": "https://api.openai.com", + }, + usage={"input_tokens": 12, "output_tokens": 3}, + ): + pass + + spans = exporter.get_finished_spans() + client_span = next( + span for span in spans if span.kind is SpanKind.CLIENT + ) + + assert client_span.attributes[GenAI.GEN_AI_SYSTEM] == "openai" + assert client_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "chat" + assert ( + client_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] == "gpt-4o-mini" + ) + assert client_span.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 12 + assert client_span.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 3 + assert ( + client_span.attributes[ServerAttributes.SERVER_ADDRESS] + == "api.openai.com" + ) + finally: + instrumentor.uninstrument() + exporter.clear() + + +def test_function_span_records_tool_attributes(): + instrumentor, exporter = _instrument_with_provider() + + try: + with trace("workflow"): + with function_span( + name="fetch_weather", input='{"city": "Paris"}' + ): + pass + + spans = exporter.get_finished_spans() + tool_span = next( + span for span in spans if span.kind is SpanKind.INTERNAL + ) + + assert ( + tool_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "execute_tool" + ) + assert tool_span.attributes[GenAI.GEN_AI_TOOL_NAME] == "fetch_weather" + assert tool_span.attributes[GenAI.GEN_AI_TOOL_TYPE] == "function" + assert tool_span.attributes[GenAI.GEN_AI_SYSTEM] == "openai" + finally: + instrumentor.uninstrument() + exporter.clear() From 44b91e8d510da71a77126657e861165b0760ee0e Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Wed, 8 Oct 2025 06:38:12 -0700 Subject: [PATCH 02/18] Update OpenAI Agents changelog with PR references --- .../opentelemetry-instrumentation-openai-agents/CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/CHANGELOG.md index f01a53155a..d4fed08923 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/CHANGELOG.md @@ -9,3 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial barebones package skeleton: minimal instrumentor stub, version module, and packaging metadata/entry point. + ([#3805](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3805)) +- Implement OpenAI Agents span processing aligned with GenAI semantic conventions. + ([#3817](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3817)) From 1d78868c387a3345837b822db67fdae27bd030d5 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Wed, 8 Oct 2025 11:32:41 -0700 Subject: [PATCH 03/18] Add OpenAI Agents manual and zero-code examples --- .../examples/manual/.env | 11 ++++ .../examples/manual/README.rst | 37 +++++++++++ .../examples/manual/main.py | 63 +++++++++++++++++++ .../examples/manual/requirements.txt | 5 ++ .../examples/zero-code/.env | 14 +++++ .../examples/zero-code/README.rst | 37 +++++++++++ .../examples/zero-code/main.py | 39 ++++++++++++ .../examples/zero-code/requirements.txt | 6 ++ 8 files changed, 212 insertions(+) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env new file mode 100644 index 0000000000..84c2f28c7f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env @@ -0,0 +1,11 @@ +# Update this with your real OpenAI API key +OPENAI_API_KEY=sk-YOUR_API_KEY + +# Uncomment and adjust if you use a non-default OTLP collector endpoint +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +OTEL_SERVICE_NAME=opentelemetry-python-openai-agents-manual + +# Optionally override the agent name reported on spans +# OTEL_GENAI_AGENT_NAME=Travel Concierge diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst new file mode 100644 index 0000000000..5a784edce2 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst @@ -0,0 +1,37 @@ +OpenTelemetry OpenAI Agents Instrumentation Example +=================================================== + +This example demonstrates how to manually configure the OpenTelemetry SDK +alongside the OpenAI Agents instrumentation. + +Running `main.py `_ produces spans for the end-to-end agent run, +including tool invocations and model generations. Spans are exported through +OTLP/gRPC to the endpoint configured in the environment. + +Setup +----- + +1. Update the `.env <.env>`_ file with your real ``OPENAI_API_KEY``. If your + OTLP collector is not reachable via ``http://localhost:4317``, adjust the + endpoint variables as needed. +2. Create a virtual environment and install the dependencies: + + :: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +Run +--- + +Execute the sample with ``dotenv`` so the environment variables from ``.env`` +are applied: + +:: + + dotenv run -- python main.py + +You should see the agent response printed to the console while spans export to +your configured observability backend. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py new file mode 100644 index 0000000000..4ace49c78a --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py @@ -0,0 +1,63 @@ +# pylint: skip-file +"""Manual OpenAI Agents instrumentation example.""" + +from __future__ import annotations + +from agents import Agent, Runner, function_tool + +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.instrumentation.openai_agents import ( + OpenAIAgentsInstrumentor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +def configure_otel() -> None: + """Configure the OpenTelemetry SDK for exporting spans.""" + + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) + + OpenAIAgentsInstrumentor().instrument(tracer_provider=provider) + + +@function_tool +def get_weather(city: str) -> str: + """Return a canned weather response for the requested city.""" + + return f"The forecast for {city} is sunny with pleasant temperatures." + + +def run_agent() -> None: + """Create a simple agent and execute a single run.""" + + assistant = Agent( + name="Travel Concierge", + instructions=( + "You are a concise travel concierge. Use the weather tool when the" + " traveler asks about local conditions." + ), + tools=[get_weather], + ) + + result = Runner.run_sync( + assistant, + "I'm visiting Barcelona this weekend. How should I pack?", + ) + + print("Agent response:") + print(result.final_output) + + +def main() -> None: + configure_otel() + run_agent() + + +if __name__ == "__main__": + main() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt new file mode 100644 index 0000000000..96e6e4cec0 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt @@ -0,0 +1,5 @@ +openai-agents~=0.3.3 + +opentelemetry-sdk~=1.36.0 +opentelemetry-exporter-otlp-proto-grpc~=1.36.0 +opentelemetry-instrumentation-openai-agents~=0.1.0.dev diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env new file mode 100644 index 0000000000..8f39668502 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env @@ -0,0 +1,14 @@ +# Update this with your real OpenAI API key +OPENAI_API_KEY=sk-YOUR_API_KEY + +# Uncomment and adjust if you use a non-default OTLP collector endpoint +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +OTEL_SERVICE_NAME=opentelemetry-python-openai-agents-zero-code + +# Enable auto-instrumentation for logs if desired +OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED=true + +# Optionally override the agent name reported on spans +# OTEL_GENAI_AGENT_NAME=Travel Concierge diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst new file mode 100644 index 0000000000..40c45f27e9 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst @@ -0,0 +1,37 @@ +OpenTelemetry OpenAI Agents Zero-Code Instrumentation Example +============================================================= + +This example shows how to capture telemetry from OpenAI Agents without +changing your application code by using ``opentelemetry-instrument``. + +When `main.py `_ is executed, spans describing the agent workflow are +exported to the configured OTLP endpoint. The spans include details such as the +operation name, tool usage, and token consumption (when available). + +Setup +----- + +1. Update the `.env <.env>`_ file with your real ``OPENAI_API_KEY``. Adjust the + OTLP endpoint settings if your collector is not reachable via + ``http://localhost:4317``. +2. Create a virtual environment and install the dependencies: + + :: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +Run +--- + +Execute the sample via ``opentelemetry-instrument`` so the OpenAI Agents +instrumentation is activated automatically: + +:: + + dotenv run -- opentelemetry-instrument python main.py + +You should see the agent response printed to the console while spans export to +your observability backend. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py new file mode 100644 index 0000000000..4af3e61908 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py @@ -0,0 +1,39 @@ +"""Zero-code OpenAI Agents example.""" + +from __future__ import annotations + +from agents import Agent, Runner, function_tool + + +@function_tool +def get_weather(city: str) -> str: + """Return a canned weather response for the requested city.""" + + return f"The forecast for {city} is sunny with pleasant temperatures." + + +def run_agent() -> None: + assistant = Agent( + name="Travel Concierge", + instructions=( + "You are a concise travel concierge. Use the weather tool when the" + " traveler asks about local conditions." + ), + tools=[get_weather], + ) + + result = Runner.run_sync( + assistant, + "I'm visiting Barcelona this weekend. How should I pack?", + ) + + print("Agent response:") + print(result.final_output) + + +def main() -> None: + run_agent() + + +if __name__ == "__main__": + main() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt new file mode 100644 index 0000000000..3db3b4b863 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt @@ -0,0 +1,6 @@ +openai-agents~=0.3.3 + +opentelemetry-sdk~=1.36.0 +opentelemetry-exporter-otlp-proto-grpc~=1.36.0 +opentelemetry-distro~=0.57b0 +opentelemetry-instrumentation-openai-agents~=0.1.0.dev From f0154ea7a4bdbc6299a97bd442fa46d1a4078e03 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Wed, 8 Oct 2025 12:03:41 -0700 Subject: [PATCH 04/18] Load dotenv in OpenAI Agents examples --- .../examples/manual/README.rst | 4 ++++ .../examples/manual/main.py | 2 ++ .../examples/manual/requirements.txt | 1 + .../examples/zero-code/README.rst | 3 +++ .../examples/zero-code/main.py | 2 ++ .../examples/zero-code/requirements.txt | 1 + 6 files changed, 13 insertions(+) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst index 5a784edce2..e217e07d66 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst @@ -33,5 +33,9 @@ are applied: dotenv run -- python main.py +The script automatically loads environment variables from ``.env`` so running +``python main.py`` directly also works if the shell already has the required +values exported. + You should see the agent response printed to the console while spans export to your configured observability backend. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py index 4ace49c78a..a750c94f6c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/main.py @@ -4,6 +4,7 @@ from __future__ import annotations from agents import Agent, Runner, function_tool +from dotenv import load_dotenv from opentelemetry import trace from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( @@ -55,6 +56,7 @@ def run_agent() -> None: def main() -> None: + load_dotenv() configure_otel() run_agent() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt index 96e6e4cec0..3510fe42eb 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/requirements.txt @@ -1,4 +1,5 @@ openai-agents~=0.3.3 +python-dotenv~=1.0 opentelemetry-sdk~=1.36.0 opentelemetry-exporter-otlp-proto-grpc~=1.36.0 diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst index 40c45f27e9..5ab0e00cd3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst @@ -33,5 +33,8 @@ instrumentation is activated automatically: dotenv run -- opentelemetry-instrument python main.py +Because ``main.py`` invokes ``load_dotenv``, running ``python main.py`` directly +also works when the required environment variables are already exported. + You should see the agent response printed to the console while spans export to your observability backend. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py index 4af3e61908..65ec99a57c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py @@ -3,6 +3,7 @@ from __future__ import annotations from agents import Agent, Runner, function_tool +from dotenv import load_dotenv @function_tool @@ -32,6 +33,7 @@ def run_agent() -> None: def main() -> None: + load_dotenv() run_agent() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt index 3db3b4b863..de86e88601 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/requirements.txt @@ -1,4 +1,5 @@ openai-agents~=0.3.3 +python-dotenv~=1.0 opentelemetry-sdk~=1.36.0 opentelemetry-exporter-otlp-proto-grpc~=1.36.0 From ed61f0d08997e671476121469b977b41dacca27e Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Wed, 8 Oct 2025 13:19:20 -0700 Subject: [PATCH 05/18] Update the tracer and finalize tests --- .../opentelemetry-instrumentation-openai-agents/.gitignore | 1 + .../examples/manual/{.env => .env.example} | 0 .../examples/zero-code/{.env => .env.example} | 0 .../instrumentation/openai_agents/span_processor.py | 6 +++--- 4 files changed, 4 insertions(+), 3 deletions(-) rename instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/{.env => .env.example} (100%) rename instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/{.env => .env.example} (100%) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/.gitignore b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/.gitignore index 639ef5d194..b6abc5e16a 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/.gitignore +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/.gitignore @@ -1,2 +1,3 @@ examples/.env examples/openai_agents_multi_agent_travel/.env +examples/**/.env diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env.example similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env rename to instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/.env.example diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env.example similarity index 100% rename from instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env rename to instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/.env.example diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index 872bf0f8ad..f19101afee 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -28,8 +28,8 @@ class TracingProcessor: # type: ignore[misc] from opentelemetry.trace.status import Status, StatusCode -def _parse_iso8601(timestamp: str | None) -> float | None: - """Return POSIX timestamp (seconds) for ISO8601 string.""" +def _parse_iso8601(timestamp: str | None) -> int | None: + """Return nanosecond timestamp for ISO8601 string.""" if not timestamp: return None @@ -41,7 +41,7 @@ def _parse_iso8601(timestamp: str | None) -> float | None: except ValueError: return None - return dt.timestamp() + return int(dt.timestamp() * 1_000_000_000) def _extract_server_attributes( From 96335850d4aef10b1901a2f0dc5c3079267c31f9 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Wed, 8 Oct 2025 16:54:18 -0700 Subject: [PATCH 06/18] Capture spans from zero code sample --- .../examples/manual/README.rst | 3 ++- .../examples/zero-code/README.rst | 3 ++- .../examples/zero-code/main.py | 25 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst index e217e07d66..1f3be9f4de 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/manual/README.rst @@ -11,7 +11,8 @@ OTLP/gRPC to the endpoint configured in the environment. Setup ----- -1. Update the `.env <.env>`_ file with your real ``OPENAI_API_KEY``. If your +1. Copy `.env.example <.env.example>`_ to `.env` and update it with your real + ``OPENAI_API_KEY``. If your OTLP collector is not reachable via ``http://localhost:4317``, adjust the endpoint variables as needed. 2. Create a virtual environment and install the dependencies: diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst index 5ab0e00cd3..75a9ff5385 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/README.rst @@ -11,7 +11,8 @@ operation name, tool usage, and token consumption (when available). Setup ----- -1. Update the `.env <.env>`_ file with your real ``OPENAI_API_KEY``. Adjust the +1. Copy `.env.example <.env.example>`_ to `.env` and update it with your real + ``OPENAI_API_KEY``. Adjust the OTLP endpoint settings if your collector is not reachable via ``http://localhost:4317``. 2. Create a virtual environment and install the dependencies: diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py index 65ec99a57c..4f59c01644 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/zero-code/main.py @@ -5,6 +5,30 @@ from agents import Agent, Runner, function_tool from dotenv import load_dotenv +from opentelemetry import trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.instrumentation.openai_agents import ( + OpenAIAgentsInstrumentor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +def configure_tracing() -> None: + """Ensure tracing exports spans even without auto-instrumentation.""" + + current_provider = trace.get_tracer_provider() + if isinstance(current_provider, TracerProvider): + provider = current_provider + else: + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + trace.set_tracer_provider(provider) + + OpenAIAgentsInstrumentor().instrument(tracer_provider=provider) + @function_tool def get_weather(city: str) -> str: @@ -34,6 +58,7 @@ def run_agent() -> None: def main() -> None: load_dotenv() + configure_tracing() run_agent() From ba45d16d382b55c846f0a123ab65794a237a5d8d Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 08:38:49 -0700 Subject: [PATCH 07/18] Default OpenAI agent trace start to now --- .../openai_agents/span_processor.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index f19101afee..cbd51bb91f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from datetime import datetime from threading import RLock +from time import time_ns from typing import Any from urllib.parse import urlparse @@ -27,6 +28,15 @@ class TracingProcessor: # type: ignore[misc] from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context from opentelemetry.trace.status import Status, StatusCode +_CLIENT_SPAN_TYPES = frozenset( + { + "generation", + "response", + "speech", + "transcription", + } +) + def _parse_iso8601(timestamp: str | None) -> int | None: """Return nanosecond timestamp for ISO8601 string.""" @@ -143,8 +153,10 @@ def _operation_name(self, span_data: Any) -> str: def _span_kind(self, span_data: Any) -> SpanKind: span_type = getattr(span_data, "type", None) - if span_type in {"generation", "response", "speech", "transcription"}: + if span_type in _CLIENT_SPAN_TYPES: return SpanKind.CLIENT + # Tool invocations (e.g. span type "function") execute inside the agent + # runtime, so there is no remote peer to model; we keep them INTERNAL. return SpanKind.INTERNAL def _span_name(self, operation: str, attributes: Mapping[str, Any]) -> str: @@ -330,7 +342,9 @@ def _attributes_for_span(self, span_data: Any) -> dict[str, Any]: def on_trace_start(self, trace: AgentsTrace) -> None: attributes = self._base_attributes() - start_time = _parse_iso8601(getattr(trace, "started_at", None)) + start_time = ( + _parse_iso8601(getattr(trace, "started_at", None)) or time_ns() + ) with self._lock: span = self._tracer.start_span( From 20e80a39571638d5399070d5ef516460f68d0dea Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 08:43:30 -0700 Subject: [PATCH 08/18] Annotate OpenAI trace provider helper --- .../instrumentation/openai_agents/__init__.py | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py index 56861774c2..95ea1edb5e 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py @@ -18,7 +18,7 @@ import importlib import os -from typing import Collection +from typing import TYPE_CHECKING, Any, Collection, Protocol from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.semconv._incubating.attributes import ( @@ -31,6 +31,20 @@ from .span_processor import _OpenAIAgentsSpanProcessor from .version import __version__ # noqa: F401 +if TYPE_CHECKING: + from agents.tracing.processor_interface import TracingProcessor +else: # pragma: no cover - runtime fallback when Agents SDK isn't installed + TracingProcessor = Any + + +class _ProcessorHolder(Protocol): + _processors: Collection[TracingProcessor] + + +class _TraceProviderLike(Protocol): + _multi_processor: _ProcessorHolder + + __all__ = ["OpenAIAgentsInstrumentor"] @@ -51,7 +65,14 @@ def _resolve_system(value: str | None) -> str: return value -def _get_registered_processors(provider) -> list: +def _get_registered_processors( + provider: _TraceProviderLike, +) -> list[TracingProcessor]: + """Return tracing processors registered on the OpenAI Agents trace provider. + + The provider exposes a private `_multi_processor` attribute with a `_processors` + collection that stores the currently registered processors in execution order. + """ multi = getattr(provider, "_multi_processor", None) processors = getattr(multi, "_processors", ()) return list(processors) From 5165bad46692752fa9f5807a626fc1343a8714f3 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 08:49:36 -0700 Subject: [PATCH 09/18] Remove OpenAI Agents system env override --- .../opentelemetry/instrumentation/openai_agents/__init__.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py index 95ea1edb5e..30e45f9c63 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py @@ -17,7 +17,6 @@ from __future__ import annotations import importlib -import os from typing import TYPE_CHECKING, Any, Collection, Protocol from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -97,10 +96,7 @@ def _instrument(self, **kwargs) -> None: schema_url=Schemas.V1_28_0.value, ) - system_override = kwargs.get("system") or os.getenv( - "OTEL_INSTRUMENTATION_OPENAI_AGENTS_SYSTEM" - ) - system = _resolve_system(system_override) + system = _resolve_system(kwargs.get("system")) processor = _OpenAIAgentsSpanProcessor(tracer=tracer, system=system) From fd707d36c2247efddfe3174c9d533ca30bb566d4 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 08:56:26 -0700 Subject: [PATCH 10/18] Use gen_ai.provider.name for OpenAI Agents spans --- .../instrumentation/openai_agents/span_processor.py | 4 +++- .../tests/test_tracer.py | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index cbd51bb91f..c6a578bd0f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -37,6 +37,8 @@ class TracingProcessor: # type: ignore[misc] } ) +_GEN_AI_PROVIDER_NAME = "gen_ai.provider.name" + def _parse_iso8601(timestamp: str | None) -> int | None: """Return nanosecond timestamp for ISO8601 string.""" @@ -180,7 +182,7 @@ def _span_name(self, operation: str, attributes: Mapping[str, Any]) -> str: return operation def _base_attributes(self) -> dict[str, Any]: - return {GenAI.GEN_AI_SYSTEM: self._system} + return {_GEN_AI_PROVIDER_NAME: self._system} def _attributes_from_generation(self, span_data: Any) -> dict[str, Any]: attributes = self._base_attributes() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index f80d897132..dbcf2fb2dd 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -68,7 +68,7 @@ def test_generation_span_creates_client_span(): span for span in spans if span.kind is SpanKind.CLIENT ) - assert client_span.attributes[GenAI.GEN_AI_SYSTEM] == "openai" + assert client_span.attributes["gen_ai.provider.name"] == "openai" assert client_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "chat" assert ( client_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] == "gpt-4o-mini" @@ -104,7 +104,7 @@ def test_function_span_records_tool_attributes(): ) assert tool_span.attributes[GenAI.GEN_AI_TOOL_NAME] == "fetch_weather" assert tool_span.attributes[GenAI.GEN_AI_TOOL_TYPE] == "function" - assert tool_span.attributes[GenAI.GEN_AI_SYSTEM] == "openai" + assert tool_span.attributes["gen_ai.provider.name"] == "openai" finally: instrumentor.uninstrument() exporter.clear() From 523e7e2aff82bca8bb700a84f881da52260cfe28 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 08:59:15 -0700 Subject: [PATCH 11/18] Support new SDK InMemorySpanExporter import in tests --- .../tests/test_tracer.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index dbcf2fb2dd..1073ee4041 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -22,10 +22,19 @@ OpenAIAgentsInstrumentor, ) from opentelemetry.sdk.trace import TracerProvider # noqa: E402 -from opentelemetry.sdk.trace.export import ( # noqa: E402 - InMemorySpanExporter, - SimpleSpanProcessor, -) + +try: + from opentelemetry.sdk.trace.export import ( # type: ignore[attr-defined] + InMemorySpanExporter, + SimpleSpanProcessor, + ) +except ImportError: # pragma: no cover - support older/newer SDK layouts + from opentelemetry.sdk.trace.export import ( + SimpleSpanProcessor, # noqa: E402 + ) + from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( # noqa: E402 + InMemorySpanExporter, + ) from opentelemetry.semconv._incubating.attributes import ( # noqa: E402 gen_ai_attributes as GenAI, ) From 3c5fd9aa066a58b76d17a41b77d4dd22206e86fe Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 09:01:26 -0700 Subject: [PATCH 12/18] Ensure OpenAI agent span names include model when available --- .../instrumentation/openai_agents/span_processor.py | 4 +++- .../tests/test_tracer.py | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index c6a578bd0f..00962a4dfd 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -162,7 +162,9 @@ def _span_kind(self, span_data: Any) -> SpanKind: return SpanKind.INTERNAL def _span_name(self, operation: str, attributes: Mapping[str, Any]) -> str: - model = attributes.get(GenAI.GEN_AI_REQUEST_MODEL) + model = attributes.get(GenAI.GEN_AI_REQUEST_MODEL) or attributes.get( + GenAI.GEN_AI_RESPONSE_MODEL + ) agent_name = attributes.get(GenAI.GEN_AI_AGENT_NAME) tool_name = attributes.get(GenAI.GEN_AI_TOOL_NAME) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index 1073ee4041..4f3918f5a1 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -82,6 +82,7 @@ def test_generation_span_creates_client_span(): assert ( client_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] == "gpt-4o-mini" ) + assert client_span.name == "chat gpt-4o-mini" assert client_span.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 12 assert client_span.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 3 assert ( From c032131e381b515e69a8a8b1fab8486759331781 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 09:09:27 -0700 Subject: [PATCH 13/18] Handle agent creation spans in OpenAI Agents instrumentation --- .../openai_agents/span_processor.py | 47 +++++++++++++++++++ .../tests/stubs/agents/tracing/__init__.py | 16 +++++++ .../tests/test_tracer.py | 40 ++++++++++++++++ 3 files changed, 103 insertions(+) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index 00962a4dfd..1cc1beba20 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -34,6 +34,8 @@ class TracingProcessor: # type: ignore[misc] "response", "speech", "transcription", + "agent", + "agent_creation", } ) @@ -141,12 +143,24 @@ def __init__(self, tracer: Tracer, system: str) -> None: def _operation_name(self, span_data: Any) -> str: span_type = getattr(span_data, "type", None) + explicit_operation = getattr(span_data, "operation", None) + normalized_operation = ( + explicit_operation.strip().lower() + if isinstance(explicit_operation, str) + else None + ) if span_type == "generation": if _looks_like_chat(getattr(span_data, "input", None)): return GenAI.GenAiOperationNameValues.CHAT.value return GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value if span_type == "agent": + if normalized_operation in {"create", "create_agent"}: + return GenAI.GenAiOperationNameValues.CREATE_AGENT.value + if normalized_operation in {"invoke", "invoke_agent"}: + return GenAI.GenAiOperationNameValues.INVOKE_AGENT.value return GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + if span_type == "agent_creation": + return GenAI.GenAiOperationNameValues.CREATE_AGENT.value if span_type == "function": return GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value if span_type == "response": @@ -303,6 +317,31 @@ def _attributes_from_agent(self, span_data: Any) -> dict[str, Any]: return attributes + def _attributes_from_agent_creation( + self, span_data: Any + ) -> dict[str, Any]: + attributes = self._base_attributes() + attributes[GenAI.GEN_AI_OPERATION_NAME] = ( + GenAI.GenAiOperationNameValues.CREATE_AGENT.value + ) + + name = getattr(span_data, "name", None) + if name: + attributes[GenAI.GEN_AI_AGENT_NAME] = name + description = getattr(span_data, "description", None) + if description: + attributes[GenAI.GEN_AI_AGENT_DESCRIPTION] = description + agent_id = getattr(span_data, "agent_id", None) or getattr( + span_data, "id", None + ) + if agent_id: + attributes[GenAI.GEN_AI_AGENT_ID] = agent_id + model = getattr(span_data, "model", None) + if model: + attributes[GenAI.GEN_AI_REQUEST_MODEL] = model + + return attributes + def _attributes_from_function(self, span_data: Any) -> dict[str, Any]: attributes = self._base_attributes() attributes[GenAI.GEN_AI_OPERATION_NAME] = ( @@ -330,7 +369,15 @@ def _attributes_for_span(self, span_data: Any) -> dict[str, Any]: if span_type == "response": return self._attributes_from_response(span_data) if span_type == "agent": + operation = getattr(span_data, "operation", None) + if isinstance(operation, str) and operation.strip().lower() in { + "create", + "create_agent", + }: + return self._attributes_from_agent_creation(span_data) return self._attributes_from_agent(span_data) + if span_type == "agent_creation": + return self._attributes_from_agent_creation(span_data) if span_type == "function": return self._attributes_from_function(span_data) if span_type in { diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py index d200b6d1d2..2c7b3b7df9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py @@ -14,6 +14,7 @@ "get_trace_provider", "set_trace_processors", "trace", + "agent_span", "generation_span", "function_span", "AgentSpanData", @@ -27,6 +28,10 @@ class AgentSpanData: name: str | None = None tools: list[str] | None = None output_type: str | None = None + description: str | None = None + agent_id: str | None = None + model: str | None = None + operation: str | None = None @property def type(self) -> str: @@ -176,6 +181,17 @@ def generation_span(**kwargs: Any): span.finish() +@contextmanager +def agent_span(**kwargs: Any): + data = AgentSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() + + @contextmanager def function_span(**kwargs: Any): data = FunctionSpanData(**kwargs) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index 4f3918f5a1..524e28dd8b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -12,6 +12,7 @@ sys.modules.pop("agents.tracing", None) from agents.tracing import ( # noqa: E402 + agent_span, function_span, generation_span, set_trace_processors, @@ -118,3 +119,42 @@ def test_function_span_records_tool_attributes(): finally: instrumentor.uninstrument() exporter.clear() + + +def test_agent_create_span_records_attributes(): + instrumentor, exporter = _instrument_with_provider() + + try: + with trace("workflow"): + with agent_span( + operation="create", + name="support_bot", + description="Answers support questions", + agent_id="agt_123", + model="gpt-4o-mini", + ): + pass + + spans = exporter.get_finished_spans() + create_span = next( + span + for span in spans + if span.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.CREATE_AGENT.value + ) + + assert create_span.kind is SpanKind.CLIENT + assert create_span.name == "create_agent support_bot" + assert create_span.attributes["gen_ai.provider.name"] == "openai" + assert create_span.attributes[GenAI.GEN_AI_AGENT_NAME] == "support_bot" + assert ( + create_span.attributes[GenAI.GEN_AI_AGENT_DESCRIPTION] + == "Answers support questions" + ) + assert create_span.attributes[GenAI.GEN_AI_AGENT_ID] == "agt_123" + assert ( + create_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] == "gpt-4o-mini" + ) + finally: + instrumentor.uninstrument() + exporter.clear() From b3cc03a5d92dbbff0ee174e98240a0abd1b9f2c7 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 09:16:34 -0700 Subject: [PATCH 14/18] Allow overriding OpenAI agent name via environment variable --- .../instrumentation/openai_agents/__init__.py | 10 +++++- .../openai_agents/span_processor.py | 17 ++++++++-- .../tests/test_tracer.py | 33 +++++++++++++++++-- 3 files changed, 54 insertions(+), 6 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py index 30e45f9c63..985760f5a9 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/__init__.py @@ -17,6 +17,7 @@ from __future__ import annotations import importlib +import os from typing import TYPE_CHECKING, Any, Collection, Protocol from opentelemetry.instrumentation.instrumentor import BaseInstrumentor @@ -97,8 +98,15 @@ def _instrument(self, **kwargs) -> None: ) system = _resolve_system(kwargs.get("system")) + agent_name_override = kwargs.get("agent_name") or os.getenv( + "OTEL_GENAI_AGENT_NAME" + ) - processor = _OpenAIAgentsSpanProcessor(tracer=tracer, system=system) + processor = _OpenAIAgentsSpanProcessor( + tracer=tracer, + system=system, + agent_name_override=agent_name_override, + ) tracing = _load_tracing_module() provider = tracing.get_trace_provider() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index 1cc1beba20..b5f7c65d0b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -134,9 +134,20 @@ class _SpanContext: class _OpenAIAgentsSpanProcessor(TracingProcessor): """Convert OpenAI Agents traces into OpenTelemetry spans.""" - def __init__(self, tracer: Tracer, system: str) -> None: + def __init__( + self, + tracer: Tracer, + system: str, + agent_name_override: str | None = None, + ) -> None: self._tracer = tracer self._system = system + self._agent_name_override = ( + agent_name_override.strip() + if isinstance(agent_name_override, str) + and agent_name_override.strip() + else None + ) self._root_spans: dict[str, Span] = {} self._spans: dict[str, _SpanContext] = {} self._lock = RLock() @@ -308,7 +319,7 @@ def _attributes_from_agent(self, span_data: Any) -> dict[str, Any]: GenAI.GenAiOperationNameValues.INVOKE_AGENT.value ) - name = getattr(span_data, "name", None) + name = self._agent_name_override or getattr(span_data, "name", None) if name: attributes[GenAI.GEN_AI_AGENT_NAME] = name output_type = getattr(span_data, "output_type", None) @@ -325,7 +336,7 @@ def _attributes_from_agent_creation( GenAI.GenAiOperationNameValues.CREATE_AGENT.value ) - name = getattr(span_data, "name", None) + name = self._agent_name_override or getattr(span_data, "name", None) if name: attributes[GenAI.GEN_AI_AGENT_NAME] = name description = getattr(span_data, "description", None) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index 524e28dd8b..d6136d5651 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -45,14 +45,14 @@ from opentelemetry.trace import SpanKind # noqa: E402 -def _instrument_with_provider(): +def _instrument_with_provider(**instrument_kwargs): set_trace_processors([]) provider = TracerProvider() exporter = InMemorySpanExporter() provider.add_span_processor(SimpleSpanProcessor(exporter)) instrumentor = OpenAIAgentsInstrumentor() - instrumentor.instrument(tracer_provider=provider) + instrumentor.instrument(tracer_provider=provider, **instrument_kwargs) return instrumentor, exporter @@ -158,3 +158,32 @@ def test_agent_create_span_records_attributes(): finally: instrumentor.uninstrument() exporter.clear() + + +def test_agent_name_override_applied_to_agent_spans(): + instrumentor, exporter = _instrument_with_provider( + agent_name="Travel Concierge" + ) + + try: + with trace("workflow"): + with agent_span(operation="invoke", name="support_bot"): + pass + + spans = exporter.get_finished_spans() + agent_span_record = next( + span + for span in spans + if span.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value + ) + + assert agent_span_record.kind is SpanKind.CLIENT + assert agent_span_record.name == "invoke_agent Travel Concierge" + assert ( + agent_span_record.attributes[GenAI.GEN_AI_AGENT_NAME] + == "Travel Concierge" + ) + finally: + instrumentor.uninstrument() + exporter.clear() From c813d083e98f8a22111e9646013f70975afad714 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 09:22:22 -0700 Subject: [PATCH 15/18] Define span type constants for OpenAI Agents instrumentation --- .../openai_agents/span_processor.py | 56 +++++++++++-------- .../tests/stubs/agents/tracing/__init__.py | 10 +++- 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index b5f7c65d0b..2d4d97bb9d 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -28,14 +28,26 @@ class TracingProcessor: # type: ignore[misc] from opentelemetry.trace import Span, SpanKind, Tracer, set_span_in_context from opentelemetry.trace.status import Status, StatusCode +SPAN_TYPE_GENERATION = "generation" +SPAN_TYPE_RESPONSE = "response" +SPAN_TYPE_AGENT = "agent" +SPAN_TYPE_AGENT_CREATION = "agent_creation" +SPAN_TYPE_FUNCTION = "function" +SPAN_TYPE_SPEECH = "speech" +SPAN_TYPE_TRANSCRIPTION = "transcription" +SPAN_TYPE_SPEECH_GROUP = "speech_group" +SPAN_TYPE_GUARDRAIL = "guardrail" +SPAN_TYPE_HANDOFF = "handoff" +SPAN_TYPE_MCP_TOOLS = "mcp_tools" + _CLIENT_SPAN_TYPES = frozenset( { - "generation", - "response", - "speech", - "transcription", - "agent", - "agent_creation", + SPAN_TYPE_GENERATION, + SPAN_TYPE_RESPONSE, + SPAN_TYPE_SPEECH, + SPAN_TYPE_TRANSCRIPTION, + SPAN_TYPE_AGENT, + SPAN_TYPE_AGENT_CREATION, } ) @@ -160,21 +172,21 @@ def _operation_name(self, span_data: Any) -> str: if isinstance(explicit_operation, str) else None ) - if span_type == "generation": + if span_type == SPAN_TYPE_GENERATION: if _looks_like_chat(getattr(span_data, "input", None)): return GenAI.GenAiOperationNameValues.CHAT.value return GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value - if span_type == "agent": + if span_type == SPAN_TYPE_AGENT: if normalized_operation in {"create", "create_agent"}: return GenAI.GenAiOperationNameValues.CREATE_AGENT.value if normalized_operation in {"invoke", "invoke_agent"}: return GenAI.GenAiOperationNameValues.INVOKE_AGENT.value return GenAI.GenAiOperationNameValues.INVOKE_AGENT.value - if span_type == "agent_creation": + if span_type == SPAN_TYPE_AGENT_CREATION: return GenAI.GenAiOperationNameValues.CREATE_AGENT.value - if span_type == "function": + if span_type == SPAN_TYPE_FUNCTION: return GenAI.GenAiOperationNameValues.EXECUTE_TOOL.value - if span_type == "response": + if span_type == SPAN_TYPE_RESPONSE: return GenAI.GenAiOperationNameValues.CHAT.value return span_type or "operation" @@ -375,11 +387,11 @@ def _attributes_from_generic(self, span_data: Any) -> dict[str, Any]: def _attributes_for_span(self, span_data: Any) -> dict[str, Any]: span_type = getattr(span_data, "type", None) - if span_type == "generation": + if span_type == SPAN_TYPE_GENERATION: return self._attributes_from_generation(span_data) - if span_type == "response": + if span_type == SPAN_TYPE_RESPONSE: return self._attributes_from_response(span_data) - if span_type == "agent": + if span_type == SPAN_TYPE_AGENT: operation = getattr(span_data, "operation", None) if isinstance(operation, str) and operation.strip().lower() in { "create", @@ -387,17 +399,17 @@ def _attributes_for_span(self, span_data: Any) -> dict[str, Any]: }: return self._attributes_from_agent_creation(span_data) return self._attributes_from_agent(span_data) - if span_type == "agent_creation": + if span_type == SPAN_TYPE_AGENT_CREATION: return self._attributes_from_agent_creation(span_data) - if span_type == "function": + if span_type == SPAN_TYPE_FUNCTION: return self._attributes_from_function(span_data) if span_type in { - "guardrail", - "handoff", - "speech_group", - "speech", - "transcription", - "mcp_tools", + SPAN_TYPE_GUARDRAIL, + SPAN_TYPE_HANDOFF, + SPAN_TYPE_SPEECH_GROUP, + SPAN_TYPE_SPEECH, + SPAN_TYPE_TRANSCRIPTION, + SPAN_TYPE_MCP_TOOLS, }: return self._attributes_from_generic(span_data) return self._base_attributes() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py index 2c7b3b7df9..c76d3ec52f 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py @@ -9,6 +9,10 @@ from .spans import Span from .traces import Trace +SPAN_TYPE_AGENT = "agent" +SPAN_TYPE_FUNCTION = "function" +SPAN_TYPE_GENERATION = "generation" + __all__ = [ "TraceProvider", "get_trace_provider", @@ -35,7 +39,7 @@ class AgentSpanData: @property def type(self) -> str: - return "agent" + return SPAN_TYPE_AGENT @dataclass @@ -46,7 +50,7 @@ class FunctionSpanData: @property def type(self) -> str: - return "function" + return SPAN_TYPE_FUNCTION @dataclass @@ -59,7 +63,7 @@ class GenerationSpanData: @property def type(self) -> str: - return "generation" + return SPAN_TYPE_GENERATION class _ProcessorFanout(TracingProcessor): From 0ec1c82e35596ac710b1ea5047e6d7fca31789a4 Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 09:49:05 -0700 Subject: [PATCH 16/18] Add OpenAI Agents response and completion span tests --- .../tests/stubs/agents/tracing/__init__.py | 23 ++++++ .../tests/test_tracer.py | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py index c76d3ec52f..59d54ddaf8 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/stubs/agents/tracing/__init__.py @@ -12,6 +12,7 @@ SPAN_TYPE_AGENT = "agent" SPAN_TYPE_FUNCTION = "function" SPAN_TYPE_GENERATION = "generation" +SPAN_TYPE_RESPONSE = "response" __all__ = [ "TraceProvider", @@ -21,9 +22,11 @@ "agent_span", "generation_span", "function_span", + "response_span", "AgentSpanData", "GenerationSpanData", "FunctionSpanData", + "ResponseSpanData", ] @@ -66,6 +69,15 @@ def type(self) -> str: return SPAN_TYPE_GENERATION +@dataclass +class ResponseSpanData: + response: Any = None + + @property + def type(self) -> str: + return SPAN_TYPE_RESPONSE + + class _ProcessorFanout(TracingProcessor): def __init__(self) -> None: self._processors: list[TracingProcessor] = [] @@ -205,3 +217,14 @@ def function_span(**kwargs: Any): yield span finally: span.finish() + + +@contextmanager +def response_span(**kwargs: Any): + data = ResponseSpanData(**kwargs) + span = _PROVIDER.create_span(data, parent=_CURRENT_TRACE) + span.start() + try: + yield span + finally: + span.finish() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index d6136d5651..e76d1369c2 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -15,6 +15,7 @@ agent_span, function_span, generation_span, + response_span, set_trace_processors, trace, ) @@ -95,6 +96,37 @@ def test_generation_span_creates_client_span(): exporter.clear() +def test_generation_span_without_roles_uses_text_completion(): + instrumentor, exporter = _instrument_with_provider() + + try: + with trace("workflow"): + with generation_span( + input=[{"content": "tell me a joke"}], + model="gpt-4o-mini", + model_config={"temperature": 0.7}, + ): + pass + + spans = exporter.get_finished_spans() + completion_span = next( + span + for span in spans + if span.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value + ) + + assert completion_span.kind is SpanKind.CLIENT + assert completion_span.name == "text_completion gpt-4o-mini" + assert ( + completion_span.attributes[GenAI.GEN_AI_REQUEST_MODEL] + == "gpt-4o-mini" + ) + finally: + instrumentor.uninstrument() + exporter.clear() + + def test_function_span_records_tool_attributes(): instrumentor, exporter = _instrument_with_provider() @@ -187,3 +219,48 @@ def test_agent_name_override_applied_to_agent_spans(): finally: instrumentor.uninstrument() exporter.clear() + + +def test_response_span_records_response_attributes(): + instrumentor, exporter = _instrument_with_provider() + + class _Usage: + def __init__(self, input_tokens: int, output_tokens: int) -> None: + self.input_tokens = input_tokens + self.output_tokens = output_tokens + + class _Response: + def __init__(self) -> None: + self.id = "resp-123" + self.model = "gpt-4o-mini" + self.usage = _Usage(42, 9) + self.output = [{"finish_reason": "stop"}] + + try: + with trace("workflow"): + with response_span(response=_Response()): + pass + + spans = exporter.get_finished_spans() + response = next( + span + for span in spans + if span.attributes[GenAI.GEN_AI_OPERATION_NAME] + == GenAI.GenAiOperationNameValues.CHAT.value + ) + + assert response.kind is SpanKind.CLIENT + assert response.name == "chat gpt-4o-mini" + assert response.attributes["gen_ai.provider.name"] == "openai" + assert response.attributes[GenAI.GEN_AI_RESPONSE_ID] == "resp-123" + assert ( + response.attributes[GenAI.GEN_AI_RESPONSE_MODEL] == "gpt-4o-mini" + ) + assert response.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 42 + assert response.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 9 + assert response.attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] == [ + "stop" + ] + finally: + instrumentor.uninstrument() + exporter.clear() From 407fdfbf4a800aa05c7fef54968791b88c387c1e Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 09:53:44 -0700 Subject: [PATCH 17/18] Match response finish reasons tuple in tests --- .../tests/test_tracer.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index e76d1369c2..b3a15fd32c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -258,9 +258,9 @@ def __init__(self) -> None: ) assert response.attributes[GenAI.GEN_AI_USAGE_INPUT_TOKENS] == 42 assert response.attributes[GenAI.GEN_AI_USAGE_OUTPUT_TOKENS] == 9 - assert response.attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] == [ - "stop" - ] + assert response.attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] == ( + "stop", + ) finally: instrumentor.uninstrument() exporter.clear() From 0125626b7d11943002c2bb6dbfd32e167e56c56d Mon Sep 17 00:00:00 2001 From: Nagkumar Arkalgud Date: Thu, 9 Oct 2025 10:33:01 -0700 Subject: [PATCH 18/18] Add workflow root span support and handoff example --- .../examples/handoffs/.env.example | 11 ++ .../examples/handoffs/README.rst | 39 +++++ .../examples/handoffs/main.py | 162 ++++++++++++++++++ .../examples/handoffs/requirements.txt | 6 + .../openai_agents/span_processor.py | 2 +- .../tests/test_tracer.py | 37 +++- 6 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/.env.example create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/README.rst create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/main.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/requirements.txt diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/.env.example b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/.env.example new file mode 100644 index 0000000000..6c7ed0b427 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/.env.example @@ -0,0 +1,11 @@ +# Update this with your real OpenAI API key +OPENAI_API_KEY=sk-YOUR_API_KEY + +# Uncomment and adjust if you use a non-default OTLP collector endpoint +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +# OTEL_EXPORTER_OTLP_PROTOCOL=grpc + +OTEL_SERVICE_NAME=opentelemetry-python-openai-agents-handoffs + +# Optionally override the agent name reported on spans +# OTEL_GENAI_AGENT_NAME=Travel Concierge diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/README.rst b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/README.rst new file mode 100644 index 0000000000..e3bdd305d8 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/README.rst @@ -0,0 +1,39 @@ +OpenTelemetry OpenAI Agents Handoff Example +========================================== + +This example shows how the OpenTelemetry OpenAI Agents instrumentation captures +spans in a small multi-agent workflow. Three agents collaborate: a primary +concierge, a concise assistant with a random-number tool, and a Spanish +specialist reached through a handoff. Running the sample produces +``invoke_agent`` spans for each agent as well as an ``execute_tool`` span for +the random-number function. + +Setup +----- + +1. Copy `.env.example <.env.example>`_ to `.env` and populate it with your real + ``OPENAI_API_KEY``. Adjust the OTLP exporter settings if your collector does + not listen on ``http://localhost:4317``. +2. Create a virtual environment and install the dependencies: + + :: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +Run +--- + +Execute the workflow with ``dotenv`` so the environment variables from ``.env`` +are loaded automatically: + +:: + + dotenv run -- python main.py + +The script emits a short transcript to stdout while spans stream to the OTLP +endpoint defined in your environment. You should see multiple +``invoke_agent`` spans (one per agent) and an ``execute_tool`` span for the +random-number helper triggered during the run. diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/main.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/main.py new file mode 100644 index 0000000000..3a37d6f838 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/main.py @@ -0,0 +1,162 @@ +# pylint: skip-file +"""Multi-agent handoff example instrumented with OpenTelemetry.""" + +from __future__ import annotations + +import asyncio +import json +import random + +from agents import Agent, HandoffInputData, Runner, function_tool, handoff +from agents import trace as agent_trace +from agents.extensions import handoff_filters +from agents.models import is_gpt_5_default +from dotenv import load_dotenv + +from opentelemetry import trace as otel_trace +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.instrumentation.openai_agents import ( + OpenAIAgentsInstrumentor, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + + +def configure_otel() -> None: + """Configure the OpenTelemetry SDK and enable the Agents instrumentation.""" + + provider = TracerProvider() + provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter())) + otel_trace.set_tracer_provider(provider) + + OpenAIAgentsInstrumentor().instrument(tracer_provider=provider) + + +@function_tool +def random_number_tool(maximum: int) -> int: + """Return a random integer between 0 and ``maximum``.""" + + return random.randint(0, maximum) + + +def spanish_handoff_message_filter( + handoff_message_data: HandoffInputData, +) -> HandoffInputData: + """Trim the message history forwarded to the Spanish-speaking agent.""" + + if is_gpt_5_default(): + # When GPT-5 is enabled we skip additional filtering. + return HandoffInputData( + input_history=handoff_message_data.input_history, + pre_handoff_items=tuple(handoff_message_data.pre_handoff_items), + new_items=tuple(handoff_message_data.new_items), + ) + + filtered = handoff_filters.remove_all_tools(handoff_message_data) + history = ( + tuple(filtered.input_history[2:]) + if isinstance(filtered.input_history, tuple) + else filtered.input_history[2:] + ) + + return HandoffInputData( + input_history=history, + pre_handoff_items=tuple(filtered.pre_handoff_items), + new_items=tuple(filtered.new_items), + ) + + +assistant = Agent( + name="Assistant", + instructions="Be extremely concise.", + tools=[random_number_tool], +) + +spanish_assistant = Agent( + name="Spanish Assistant", + instructions="You only speak Spanish and are extremely concise.", + handoff_description="A Spanish-speaking assistant.", +) + +concierge = Agent( + name="Concierge", + instructions=( + "Be a helpful assistant. If the traveler switches to Spanish, handoff to" + " the Spanish specialist. Use the random number tool when asked for" + " numbers." + ), + handoffs=[ + handoff(spanish_assistant, input_filter=spanish_handoff_message_filter) + ], +) + + +async def run_workflow() -> None: + """Execute a conversation that triggers tool calls and handoffs.""" + + with agent_trace(workflow_name="Travel concierge handoff"): + # Step 1: Basic conversation with the initial assistant. + result = await Runner.run( + assistant, + input="I'm planning a trip to Madrid. Can you help?", + ) + + print("Step 1 complete") + + # Step 2: Ask for a random number to exercise the tool span. + result = await Runner.run( + assistant, + input=result.to_input_list() + + [ + { + "content": "Pick a lucky number between 0 and 20", + "role": "user", + } + ], + ) + + print("Step 2 complete") + + # Step 3: Continue the conversation with the concierge agent. + result = await Runner.run( + concierge, + input=result.to_input_list() + + [ + { + "content": "Recommend some sights in Madrid for a weekend trip.", + "role": "user", + } + ], + ) + + print("Step 3 complete") + + # Step 4: Switch to Spanish to cause a handoff to the specialist. + result = await Runner.run( + concierge, + input=result.to_input_list() + + [ + { + "content": "Por favor habla en español. ¿Puedes resumir el plan?", + "role": "user", + } + ], + ) + + print("Step 4 complete") + + print("\n=== Conversation Transcript ===\n") + for message in result.to_input_list(): + print(json.dumps(message, indent=2, ensure_ascii=False)) + + +def main() -> None: + load_dotenv() + configure_otel() + asyncio.run(run_workflow()) + + +if __name__ == "__main__": + main() diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/requirements.txt new file mode 100644 index 0000000000..3510fe42eb --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/examples/handoffs/requirements.txt @@ -0,0 +1,6 @@ +openai-agents~=0.3.3 +python-dotenv~=1.0 + +opentelemetry-sdk~=1.36.0 +opentelemetry-exporter-otlp-proto-grpc~=1.36.0 +opentelemetry-instrumentation-openai-agents~=0.1.0.dev diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py index 2d4d97bb9d..8c2943799b 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/src/opentelemetry/instrumentation/openai_agents/span_processor.py @@ -160,8 +160,8 @@ def __init__( and agent_name_override.strip() else None ) - self._root_spans: dict[str, Span] = {} self._spans: dict[str, _SpanContext] = {} + self._root_spans: dict[str, Span] = {} self._lock = RLock() def _operation_name(self, span_data: Any) -> str: diff --git a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py index b3a15fd32c..21ffd86ca3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py +++ b/instrumentation-genai/opentelemetry-instrumentation-openai-agents/tests/test_tracer.py @@ -75,9 +75,15 @@ def test_generation_span_creates_client_span(): pass spans = exporter.get_finished_spans() - client_span = next( - span for span in spans if span.kind is SpanKind.CLIENT - ) + client_spans = [span for span in spans if span.kind is SpanKind.CLIENT] + server_spans = [span for span in spans if span.kind is SpanKind.SERVER] + + assert len(server_spans) == 1 + server_span = server_spans[0] + assert server_span.name == "workflow" + assert server_span.attributes["gen_ai.provider.name"] == "openai" + assert client_spans + client_span = next(iter(client_spans)) assert client_span.attributes["gen_ai.provider.name"] == "openai" assert client_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "chat" @@ -115,6 +121,12 @@ def test_generation_span_without_roles_uses_text_completion(): if span.attributes[GenAI.GEN_AI_OPERATION_NAME] == GenAI.GenAiOperationNameValues.TEXT_COMPLETION.value ) + assert completion_span.kind is SpanKind.CLIENT + server_spans = [span for span in spans if span.kind is SpanKind.SERVER] + assert len(server_spans) == 1 + assert server_spans[0].name == "workflow" + assert server_spans[0].attributes["gen_ai.provider.name"] == "openai" + assert [span for span in spans if span.kind is SpanKind.CLIENT] assert completion_span.kind is SpanKind.CLIENT assert completion_span.name == "text_completion gpt-4o-mini" @@ -142,6 +154,11 @@ def test_function_span_records_tool_attributes(): span for span in spans if span.kind is SpanKind.INTERNAL ) + server_spans = [span for span in spans if span.kind is SpanKind.SERVER] + assert len(server_spans) == 1 + assert server_spans[0].name == "workflow" + assert server_spans[0].attributes["gen_ai.provider.name"] == "openai" + assert ( tool_span.attributes[GenAI.GEN_AI_OPERATION_NAME] == "execute_tool" ) @@ -174,6 +191,11 @@ def test_agent_create_span_records_attributes(): if span.attributes[GenAI.GEN_AI_OPERATION_NAME] == GenAI.GenAiOperationNameValues.CREATE_AGENT.value ) + server_spans = [span for span in spans if span.kind is SpanKind.SERVER] + assert len(server_spans) == 1 + assert server_spans[0].name == "workflow" + assert server_spans[0].attributes["gen_ai.provider.name"] == "openai" + assert [span for span in spans if span.kind is SpanKind.CLIENT] assert create_span.kind is SpanKind.CLIENT assert create_span.name == "create_agent support_bot" @@ -209,6 +231,11 @@ def test_agent_name_override_applied_to_agent_spans(): if span.attributes[GenAI.GEN_AI_OPERATION_NAME] == GenAI.GenAiOperationNameValues.INVOKE_AGENT.value ) + server_spans = [span for span in spans if span.kind is SpanKind.SERVER] + assert len(server_spans) == 1 + assert server_spans[0].name == "workflow" + assert server_spans[0].attributes["gen_ai.provider.name"] == "openai" + assert [span for span in spans if span.kind is SpanKind.CLIENT] assert agent_span_record.kind is SpanKind.CLIENT assert agent_span_record.name == "invoke_agent Travel Concierge" @@ -261,6 +288,10 @@ def __init__(self) -> None: assert response.attributes[GenAI.GEN_AI_RESPONSE_FINISH_REASONS] == ( "stop", ) + server_spans = [span for span in spans if span.kind is SpanKind.SERVER] + assert len(server_spans) == 1 + assert server_spans[0].name == "workflow" + assert server_spans[0].attributes["gen_ai.provider.name"] == "openai" finally: instrumentor.uninstrument() exporter.clear()