Skip to content

Commit 3ee93a1

Browse files
committed
rewrite openai agents instrumentation to util genai
1 parent 07d0e39 commit 3ee93a1

12 files changed

Lines changed: 240 additions & 4479 deletions

File tree

instrumentation/opentelemetry-instrumentation-genai-openai-agents/examples/manual/main.py

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,28 +6,62 @@
66

77
from __future__ import annotations
88

9-
from agents import Agent, Runner, function_tool
9+
from agents import Agent, RunConfig, Runner, function_tool
1010
from dotenv import load_dotenv
1111

1212
from opentelemetry import trace
13+
from opentelemetry._logs import set_logger_provider
14+
from opentelemetry.exporter.otlp.proto.grpc._log_exporter import (
15+
OTLPLogExporter,
16+
)
17+
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import (
18+
OTLPMetricExporter,
19+
)
1320
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
1421
OTLPSpanExporter,
1522
)
1623
from opentelemetry.instrumentation.genai.openai_agents import (
1724
OpenAIAgentsInstrumentor,
1825
)
26+
from opentelemetry.metrics import set_meter_provider
27+
from opentelemetry.sdk._logs import LoggerProvider
28+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
29+
from opentelemetry.sdk.metrics import MeterProvider
30+
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
1931
from opentelemetry.sdk.trace import TracerProvider
2032
from opentelemetry.sdk.trace.export import BatchSpanProcessor
2133

2234

23-
def configure_otel() -> None:
24-
"""Configure the OpenTelemetry SDK for exporting spans."""
35+
def configure_otel() -> (
36+
tuple[TracerProvider, MeterProvider, LoggerProvider]
37+
):
38+
"""Configure OpenTelemetry providers and install the instrumentor."""
39+
40+
tracer_provider = TracerProvider()
41+
tracer_provider.add_span_processor(
42+
BatchSpanProcessor(OTLPSpanExporter())
43+
)
44+
trace.set_tracer_provider(tracer_provider)
45+
46+
meter_provider = MeterProvider(
47+
metric_readers=[
48+
PeriodicExportingMetricReader(OTLPMetricExporter()),
49+
],
50+
)
51+
set_meter_provider(meter_provider)
2552

26-
provider = TracerProvider()
27-
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
28-
trace.set_tracer_provider(provider)
53+
logger_provider = LoggerProvider()
54+
logger_provider.add_log_record_processor(
55+
BatchLogRecordProcessor(OTLPLogExporter())
56+
)
57+
set_logger_provider(logger_provider)
2958

30-
OpenAIAgentsInstrumentor().instrument(tracer_provider=provider)
59+
OpenAIAgentsInstrumentor().instrument(
60+
tracer_provider=tracer_provider,
61+
meter_provider=meter_provider,
62+
logger_provider=logger_provider,
63+
)
64+
return tracer_provider, meter_provider, logger_provider
3165

3266

3367
@function_tool
@@ -39,7 +73,7 @@ def get_weather(city: str) -> str:
3973

4074
def main() -> None:
4175
load_dotenv()
42-
configure_otel()
76+
tracer_provider, meter_provider, logger_provider = configure_otel()
4377
weather_specialist = Agent(
4478
name="weather_specialist",
4579
instructions=(
@@ -60,13 +94,22 @@ def main() -> None:
6094
model="gpt-4o-mini",
6195
)
6296

63-
result = Runner.run_sync(
64-
triage_agent,
65-
"I'm visiting Barcelona this weekend. How should I pack?",
66-
)
67-
68-
print("Agent response:")
69-
print(result.final_output)
97+
try:
98+
# ``RunConfig.workflow_name`` is the agents library's own knob for
99+
# naming the workflow. The instrumentation reads it and emits the
100+
# value as the ``gen_ai.workflow.name`` attribute on the workflow
101+
# span — without it, the default "Agent workflow" is used.
102+
result = Runner.run_sync(
103+
triage_agent,
104+
"I'm visiting Barcelona this weekend. How should I pack?",
105+
run_config=RunConfig(workflow_name="weather-triage"),
106+
)
107+
print("Agent response:")
108+
print(result.final_output)
109+
finally:
110+
tracer_provider.shutdown()
111+
meter_provider.shutdown()
112+
logger_provider.shutdown()
70113

71114

72115
if __name__ == "__main__":
Original file line numberDiff line numberDiff line change
@@ -1,213 +1,118 @@
11
# Copyright The OpenTelemetry Authors
22
# SPDX-License-Identifier: Apache-2.0
33

4-
"""OpenAI Agents instrumentation for OpenTelemetry."""
4+
"""OpenAI Agents instrumentation for OpenTelemetry.
55
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+
"""
733

834
from __future__ import annotations
935

10-
import importlib
1136
import logging
12-
import os
1337
from typing import Any, Collection
1438

39+
from agents.tracing import (
40+
add_trace_processor,
41+
get_trace_provider,
42+
set_trace_processors,
43+
)
44+
1545
from opentelemetry.instrumentation.instrumentor import BaseInstrumentor
1646
from opentelemetry.semconv._incubating.attributes import (
1747
gen_ai_attributes as GenAI,
1848
)
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+
)
2153

2254
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
3256

33-
__all__ = [
34-
"OpenAIAgentsInstrumentor",
35-
"GenAIProvider",
36-
"GenAIOperationName",
37-
"GenAIToolType",
38-
"GenAIOutputType",
39-
"GenAIEvaluationAttributes",
40-
]
57+
__all__ = ["OpenAIAgentsInstrumentor"]
4158

4259
logger = logging.getLogger(__name__)
4360

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
6361

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.
8264
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()``:
12566
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.
12672
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+
"""
12979

13080
def __init__(self) -> None:
13181
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
13386

134-
def _instrument(self, **kwargs) -> None:
87+
def _instrument(self, **kwargs: Any) -> None:
13588
if self._processor is not None:
13689
return
13790

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"),
14496
)
97+
provider = GenAI.GenAiProviderNameValues.OPENAI.value
98+
self._processor = GenAITracingProcessor(handler, provider)
14599

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)
190104

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:
198106
if self._processor is None:
199107
return
200108

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)
207115
try:
208116
self._processor.shutdown()
209117
finally:
210118
self._processor = None
211-
212-
def instrumentation_dependencies(self) -> Collection[str]:
213-
return _instruments

0 commit comments

Comments
 (0)