|
1 | 1 | # Copyright The OpenTelemetry Authors |
2 | 2 | # SPDX-License-Identifier: Apache-2.0 |
3 | 3 |
|
4 | | -"""OpenAI Agents instrumentation for OpenTelemetry.""" |
| 4 | +"""OpenAI Agents instrumentation for OpenTelemetry. |
5 | 5 |
|
6 | | -# pylint: disable=too-many-locals |
| 6 | +Registers a :class:`GenAITracingProcessor` with the agents library's |
| 7 | +public ``add_trace_processor`` extension API. The processor reacts |
| 8 | +synchronously to the agents library's own ``Trace`` / ``AgentSpan`` / |
| 9 | +``FunctionSpan`` / ``HandoffSpan`` start/end callbacks and turns them |
| 10 | +into ``invoke_workflow`` / ``invoke_agent`` / ``execute_tool`` spans via |
| 11 | +``opentelemetry-util-genai`` (plus a raw ``handoff`` span until handoff |
| 12 | +semconv lands). |
| 13 | +
|
| 14 | +LLM-level spans (``chat`` / ``responses`` / ``embeddings``) are produced |
| 15 | +by ``opentelemetry-instrumentation-genai-openai`` when both packages are |
| 16 | +installed; this instrumentation does not emit them. |
| 17 | +
|
| 18 | +Usage |
| 19 | +----- |
| 20 | +
|
| 21 | +.. code:: python |
| 22 | +
|
| 23 | + from opentelemetry.instrumentation.genai.openai_agents import ( |
| 24 | + OpenAIAgentsInstrumentor, |
| 25 | + ) |
| 26 | +
|
| 27 | + # Default: keep the OpenAI native trace exporter; add our OTel emission. |
| 28 | + OpenAIAgentsInstrumentor().instrument() |
| 29 | +
|
| 30 | + # Replace the default exporter so traces are only sent via OTel. |
| 31 | + OpenAIAgentsInstrumentor().instrument(disable_openai_trace_export=True) |
| 32 | +""" |
7 | 33 |
|
8 | 34 | from __future__ import annotations |
9 | 35 |
|
10 | | -import importlib |
11 | 36 | import logging |
12 | | -import os |
13 | 37 | from typing import Any, Collection |
14 | 38 |
|
| 39 | +from agents.tracing import ( |
| 40 | + add_trace_processor, |
| 41 | + get_trace_provider, |
| 42 | + set_trace_processors, |
| 43 | +) |
| 44 | + |
15 | 45 | from opentelemetry.instrumentation.instrumentor import BaseInstrumentor |
16 | 46 | from opentelemetry.semconv._incubating.attributes import ( |
17 | 47 | gen_ai_attributes as GenAI, |
18 | 48 | ) |
19 | | -from opentelemetry.semconv.schemas import Schemas |
20 | | -from opentelemetry.trace import get_tracer |
| 49 | +from opentelemetry.util.genai.handler import ( |
| 50 | + TelemetryHandler, |
| 51 | + get_telemetry_handler, |
| 52 | +) |
21 | 53 |
|
22 | 54 | from .package import _instruments |
23 | | -from .span_processor import ( |
24 | | - ContentCaptureMode, |
25 | | - GenAIEvaluationAttributes, |
26 | | - GenAIOperationName, |
27 | | - GenAIOutputType, |
28 | | - GenAIProvider, |
29 | | - GenAISemanticProcessor, |
30 | | - GenAIToolType, |
31 | | -) |
| 55 | +from .processor import GenAITracingProcessor |
32 | 56 |
|
33 | | -__all__ = [ |
34 | | - "OpenAIAgentsInstrumentor", |
35 | | - "GenAIProvider", |
36 | | - "GenAIOperationName", |
37 | | - "GenAIToolType", |
38 | | - "GenAIOutputType", |
39 | | - "GenAIEvaluationAttributes", |
40 | | -] |
| 57 | +__all__ = ["OpenAIAgentsInstrumentor"] |
41 | 58 |
|
42 | 59 | logger = logging.getLogger(__name__) |
43 | 60 |
|
44 | | -_CONTENT_CAPTURE_ENV = "OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT" |
45 | | -_SYSTEM_OVERRIDE_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_SYSTEM" |
46 | | -_CAPTURE_CONTENT_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_CONTENT" |
47 | | -_CAPTURE_METRICS_ENV = "OTEL_INSTRUMENTATION_OPENAI_AGENTS_CAPTURE_METRICS" |
48 | | - |
49 | | - |
50 | | -def _load_tracing_module(): # pragma: no cover - exercised via tests |
51 | | - return importlib.import_module("agents.tracing") |
52 | | - |
53 | | - |
54 | | -def _get_registered_processors(provider) -> list: |
55 | | - multi = getattr(provider, "_multi_processor", None) |
56 | | - processors = getattr(multi, "_processors", ()) |
57 | | - return list(processors) |
58 | | - |
59 | | - |
60 | | -def _resolve_system(value: str | None) -> str: |
61 | | - if not value: |
62 | | - return GenAI.GenAiSystemValues.OPENAI.value |
63 | 61 |
|
64 | | - normalized = value.strip().lower() |
65 | | - for member in GenAI.GenAiSystemValues: |
66 | | - if normalized == member.value: |
67 | | - return member.value |
68 | | - if normalized == member.name.lower(): |
69 | | - return member.value |
70 | | - return value |
71 | | - |
72 | | - |
73 | | -def _resolve_content_mode(value: Any) -> ContentCaptureMode: |
74 | | - if isinstance(value, ContentCaptureMode): |
75 | | - return value |
76 | | - if isinstance(value, bool): |
77 | | - return ( |
78 | | - ContentCaptureMode.SPAN_AND_EVENT |
79 | | - if value |
80 | | - else ContentCaptureMode.NO_CONTENT |
81 | | - ) |
| 62 | +class OpenAIAgentsInstrumentor(BaseInstrumentor): |
| 63 | + """Instrument the openai-agents library. |
82 | 64 |
|
83 | | - if value is None: |
84 | | - return ContentCaptureMode.SPAN_AND_EVENT |
85 | | - |
86 | | - text = str(value).strip().lower() |
87 | | - if not text: |
88 | | - return ContentCaptureMode.SPAN_AND_EVENT |
89 | | - |
90 | | - mapping = { |
91 | | - "span_only": ContentCaptureMode.SPAN_ONLY, |
92 | | - "span-only": ContentCaptureMode.SPAN_ONLY, |
93 | | - "span": ContentCaptureMode.SPAN_ONLY, |
94 | | - "event_only": ContentCaptureMode.EVENT_ONLY, |
95 | | - "event-only": ContentCaptureMode.EVENT_ONLY, |
96 | | - "event": ContentCaptureMode.EVENT_ONLY, |
97 | | - "span_and_event": ContentCaptureMode.SPAN_AND_EVENT, |
98 | | - "span-and-event": ContentCaptureMode.SPAN_AND_EVENT, |
99 | | - "span_and_events": ContentCaptureMode.SPAN_AND_EVENT, |
100 | | - "all": ContentCaptureMode.SPAN_AND_EVENT, |
101 | | - "true": ContentCaptureMode.SPAN_AND_EVENT, |
102 | | - "1": ContentCaptureMode.SPAN_AND_EVENT, |
103 | | - "yes": ContentCaptureMode.SPAN_AND_EVENT, |
104 | | - "no_content": ContentCaptureMode.NO_CONTENT, |
105 | | - "false": ContentCaptureMode.NO_CONTENT, |
106 | | - "0": ContentCaptureMode.NO_CONTENT, |
107 | | - "no": ContentCaptureMode.NO_CONTENT, |
108 | | - "none": ContentCaptureMode.NO_CONTENT, |
109 | | - } |
110 | | - |
111 | | - return mapping.get(text, ContentCaptureMode.SPAN_AND_EVENT) |
112 | | - |
113 | | - |
114 | | -def _resolve_bool(value: Any, default: bool) -> bool: |
115 | | - if value is None: |
116 | | - return default |
117 | | - if isinstance(value, bool): |
118 | | - return value |
119 | | - text = str(value).strip().lower() |
120 | | - if text in {"true", "1", "yes", "on"}: |
121 | | - return True |
122 | | - if text in {"false", "0", "no", "off"}: |
123 | | - return False |
124 | | - return default |
| 65 | + Constructor takes no arguments. Configure behavior via ``instrument()``: |
125 | 66 |
|
| 67 | + ``disable_openai_trace_export`` (default ``False``) |
| 68 | + When ``False`` (default), the agents library's built-in trace |
| 69 | + exporter (which sends traces to OpenAI's hosted tracing backend |
| 70 | + when ``OPENAI_API_KEY`` is set) remains active alongside our OTel |
| 71 | + emission. |
126 | 72 |
|
127 | | -class OpenAIAgentsInstrumentor(BaseInstrumentor): |
128 | | - """Instrumentation that bridges OpenAI Agents tracing to OpenTelemetry.""" |
| 73 | + When ``True``, the default exporter is removed via |
| 74 | + ``agents.tracing.set_trace_processors`` so traces flow only through |
| 75 | + OpenTelemetry. Any other processors previously registered by the |
| 76 | + user are also removed; if you have a custom processor list, manage |
| 77 | + it yourself instead of using this flag. |
| 78 | + """ |
129 | 79 |
|
130 | 80 | def __init__(self) -> None: |
131 | 81 | super().__init__() |
132 | | - self._processor: GenAISemanticProcessor | None = None |
| 82 | + self._processor: GenAITracingProcessor | None = None |
| 83 | + |
| 84 | + def instrumentation_dependencies(self) -> Collection[str]: |
| 85 | + return _instruments |
133 | 86 |
|
134 | | - def _instrument(self, **kwargs) -> None: |
| 87 | + def _instrument(self, **kwargs: Any) -> None: |
135 | 88 | if self._processor is not None: |
136 | 89 | return |
137 | 90 |
|
138 | | - tracer_provider = kwargs.get("tracer_provider") |
139 | | - tracer = get_tracer( |
140 | | - __name__, |
141 | | - "", |
142 | | - tracer_provider, |
143 | | - schema_url=Schemas.V1_28_0.value, |
| 91 | + handler: TelemetryHandler = get_telemetry_handler( |
| 92 | + tracer_provider=kwargs.get("tracer_provider"), |
| 93 | + meter_provider=kwargs.get("meter_provider"), |
| 94 | + logger_provider=kwargs.get("logger_provider"), |
| 95 | + completion_hook=kwargs.get("completion_hook"), |
144 | 96 | ) |
| 97 | + provider = GenAI.GenAiProviderNameValues.OPENAI.value |
| 98 | + self._processor = GenAITracingProcessor(handler, provider) |
145 | 99 |
|
146 | | - system_override = kwargs.get("system") or os.getenv( |
147 | | - _SYSTEM_OVERRIDE_ENV |
148 | | - ) |
149 | | - system = _resolve_system(system_override) |
150 | | - |
151 | | - content_override = kwargs.get("capture_message_content") |
152 | | - if content_override is None: |
153 | | - content_override = os.getenv(_CONTENT_CAPTURE_ENV) or os.getenv( |
154 | | - _CAPTURE_CONTENT_ENV |
155 | | - ) |
156 | | - content_mode = _resolve_content_mode(content_override) |
157 | | - |
158 | | - metrics_override = kwargs.get("capture_metrics") |
159 | | - if metrics_override is None: |
160 | | - metrics_override = os.getenv(_CAPTURE_METRICS_ENV) |
161 | | - metrics_enabled = _resolve_bool(metrics_override, default=True) |
162 | | - |
163 | | - agent_name = kwargs.get("agent_name") |
164 | | - agent_id = kwargs.get("agent_id") |
165 | | - agent_description = kwargs.get("agent_description") |
166 | | - base_url = kwargs.get("base_url") |
167 | | - server_address = kwargs.get("server_address") |
168 | | - server_port = kwargs.get("server_port") |
169 | | - |
170 | | - processor = GenAISemanticProcessor( |
171 | | - tracer=tracer, |
172 | | - system_name=system, |
173 | | - include_sensitive_data=content_mode |
174 | | - != ContentCaptureMode.NO_CONTENT, |
175 | | - content_mode=content_mode, |
176 | | - metrics_enabled=metrics_enabled, |
177 | | - agent_name=agent_name, |
178 | | - agent_id=agent_id, |
179 | | - agent_description=agent_description, |
180 | | - base_url=base_url, |
181 | | - server_address=server_address, |
182 | | - server_port=server_port, |
183 | | - agent_name_default="OpenAI Agent", |
184 | | - agent_id_default="agent", |
185 | | - agent_description_default="OpenAI Agents instrumentation", |
186 | | - base_url_default="https://api.openai.com", |
187 | | - server_address_default="api.openai.com", |
188 | | - server_port_default=443, |
189 | | - ) |
| 100 | + if kwargs.get("disable_openai_trace_export"): |
| 101 | + set_trace_processors([self._processor]) |
| 102 | + else: |
| 103 | + add_trace_processor(self._processor) |
190 | 104 |
|
191 | | - tracing = _load_tracing_module() |
192 | | - provider = tracing.get_trace_provider() |
193 | | - existing = _get_registered_processors(provider) |
194 | | - provider.set_processors([*existing, processor]) |
195 | | - self._processor = processor |
196 | | - |
197 | | - def _uninstrument(self, **kwargs) -> None: |
| 105 | + def _uninstrument(self, **kwargs: Any) -> None: |
198 | 106 | if self._processor is None: |
199 | 107 | return |
200 | 108 |
|
201 | | - tracing = _load_tracing_module() |
202 | | - provider = tracing.get_trace_provider() |
203 | | - current = _get_registered_processors(provider) |
204 | | - filtered = [proc for proc in current if proc is not self._processor] |
205 | | - provider.set_processors(filtered) |
206 | | - |
| 109 | + provider = get_trace_provider() |
| 110 | + current = getattr( |
| 111 | + getattr(provider, "_multi_processor", None), "_processors", () |
| 112 | + ) |
| 113 | + filtered = [p for p in current if p is not self._processor] |
| 114 | + set_trace_processors(filtered) |
207 | 115 | try: |
208 | 116 | self._processor.shutdown() |
209 | 117 | finally: |
210 | 118 | self._processor = None |
211 | | - |
212 | | - def instrumentation_dependencies(self) -> Collection[str]: |
213 | | - return _instruments |
0 commit comments