Skip to content

Commit 17eda0f

Browse files
committed
up
1 parent 3ee93a1 commit 17eda0f

4 files changed

Lines changed: 425 additions & 0 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Rewrite on top of `opentelemetry-util-genai`. The instrumentor now registers a single `TracingProcessor` with the agents library (`agents.tracing.add_trace_processor`) and turns its `Trace` / `AgentSpan` / `FunctionSpan` callbacks into `invoke_workflow` / `invoke_agent` / `execute_tool` spans. The workflow span carries a `gen_ai.workflow.name` attribute sourced from the agents library's `RunConfig.workflow_name`. LLM-level spans are not emitted by this package — install `opentelemetry-instrumentation-genai-openai` alongside to capture them. Removes the `OTEL_INSTRUMENTATION_OPENAI_AGENTS_*` environment variables and the matching instrumentor kwargs. New `disable_openai_trace_export=True` instrumentor kwarg replaces the default OpenAI-hosted trace exporter so traces flow only via OTel. Handoff / guardrail / speech / transcription spans are no longer emitted pending semconv. `execute_tool` spans are missing `gen_ai.tool.call.id` because the agents library's `FunctionSpanData` does not expose it — tracked in [#86](https://github.com/open-telemetry/opentelemetry-python-genai/issues/86).
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Bridges agents-library tracing callbacks to opentelemetry-util-genai.
5+
6+
The agents library exposes a public extension API
7+
(:func:`agents.tracing.add_trace_processor`) for plugging custom
8+
:class:`TracingProcessor` implementations into its own tracing system.
9+
``Trace.start()`` / ``Span.start()`` invoke the registered processors'
10+
``on_*_start`` callbacks *synchronously* on whichever asyncio task
11+
started the agents-library span:
12+
13+
* Workflow (``Trace``) and agent (``AgentSpanData``) spans start in the
14+
``Runner.run`` task itself.
15+
* Function tool (``FunctionSpanData``) spans start inside the per-tool
16+
``asyncio.Task`` the agents library creates for tool dispatch. That
17+
sub-task inherits a snapshot of the run-loop context (so workflow +
18+
agent are already active in OTel contextvars).
19+
20+
Because every ``*_end`` callback fires on the same task as its
21+
matching ``*_start``, util-genai's auto-``attach()`` / ``detach()`` of
22+
OTel context is balanced and no context tokens leak across tasks.
23+
OTel's natural parent tracking nests the tree:
24+
25+
workflow > invoke_agent > [chat from openai instrumentation,
26+
execute_tool]
27+
28+
LLM-level spans (``chat`` / ``responses`` / ``embeddings``) are not
29+
emitted here — ``opentelemetry-instrumentation-genai-openai`` patches
30+
the openai SDK directly and produces those.
31+
"""
32+
33+
from __future__ import annotations
34+
35+
import weakref
36+
from typing import Any
37+
38+
from agents.tracing import Span, Trace, TracingProcessor
39+
from agents.tracing.span_data import (
40+
AgentSpanData,
41+
FunctionSpanData,
42+
)
43+
44+
from opentelemetry.semconv._incubating.attributes import (
45+
gen_ai_attributes as GenAI,
46+
)
47+
from opentelemetry.util.genai.handler import TelemetryHandler
48+
from opentelemetry.util.genai.invocation import (
49+
GenAIInvocation,
50+
ToolInvocation,
51+
)
52+
53+
# Non-semconv attribute: surfaces the workflow name on the workflow span
54+
# so callers can query/filter by it. util-genai's WorkflowInvocation
55+
# only puts the name in the span name, not as an attribute.
56+
_WORKFLOW_NAME_ATTR = "gen_ai.workflow.name"
57+
58+
59+
class GenAITracingProcessor(TracingProcessor):
60+
"""Translate agents-library tracing into util-genai invocations.
61+
62+
Stateful only for span lifetime: each in-flight Trace/Span has one
63+
entry in a :class:`weakref.WeakKeyDictionary` keyed by the
64+
agents-library object itself. Entries are removed on ``*_end`` or
65+
garbage-collected with the agents-library span/trace if the library
66+
drops it before ``end`` (which it shouldn't, but the weak reference
67+
is belt-and-suspenders against any future leak).
68+
"""
69+
70+
def __init__(self, handler: TelemetryHandler, provider: str) -> None:
71+
self._handler = handler
72+
self._provider = provider
73+
self._invocations: weakref.WeakKeyDictionary[
74+
Any, GenAIInvocation
75+
] = weakref.WeakKeyDictionary()
76+
77+
def on_trace_start(self, trace: Trace) -> None:
78+
# ``trace.name`` comes from ``RunConfig.workflow_name`` (default
79+
# "Agent workflow"). Callers customize it via the agents library's
80+
# own ``Runner.run(..., run_config=RunConfig(workflow_name=...))``;
81+
# we don't expose a second knob.
82+
invocation = self._handler.workflow(name=trace.name)
83+
if trace.name:
84+
invocation.attributes[_WORKFLOW_NAME_ATTR] = trace.name
85+
self._invocations[trace] = invocation
86+
87+
def on_trace_end(self, trace: Trace) -> None:
88+
invocation = self._invocations.pop(trace, None)
89+
if invocation is not None:
90+
invocation.stop()
91+
92+
def on_span_start(self, span: Span[Any]) -> None:
93+
span_data = span.span_data
94+
if isinstance(span_data, AgentSpanData):
95+
invocation = self._handler.invoke_local_agent(
96+
provider=self._provider,
97+
agent_name=span_data.name,
98+
)
99+
self._invocations[span] = invocation
100+
return
101+
if isinstance(span_data, FunctionSpanData):
102+
invocation = self._handler.tool(
103+
name=span_data.name,
104+
arguments=span_data.input,
105+
tool_type="function",
106+
)
107+
# ToolInvocation does not include provider in metric attributes
108+
# by default; set it so gen_ai.client.operation.duration carries
109+
# the required gen_ai.provider.name attribute.
110+
invocation.metric_attributes[GenAI.GEN_AI_PROVIDER_NAME] = (
111+
self._provider
112+
)
113+
self._invocations[span] = invocation
114+
return
115+
# Other span_data types (GenerationSpanData, ResponseSpanData,
116+
# HandoffSpanData, GuardrailSpanData, Speech/TranscriptionSpanData)
117+
# are intentionally ignored. LLM-level spans come from the openai
118+
# instrumentation; the rest have no semconv yet.
119+
120+
def on_span_end(self, span: Span[Any]) -> None:
121+
invocation = self._invocations.pop(span, None)
122+
if invocation is None:
123+
return
124+
if isinstance(invocation, ToolInvocation) and isinstance(
125+
span.span_data, FunctionSpanData
126+
):
127+
output = span.span_data.output
128+
if output is not None:
129+
invocation.tool_result = (
130+
output if isinstance(output, str) else str(output)
131+
)
132+
invocation.stop()
133+
134+
def shutdown(self) -> None:
135+
for invocation in list(self._invocations.values()):
136+
try:
137+
invocation.stop()
138+
except Exception: # pylint: disable=broad-except
139+
pass
140+
self._invocations.clear()
141+
142+
def force_flush(self) -> None: # pragma: no cover - nothing to flush
143+
pass
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Copyright The OpenTelemetry Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from __future__ import annotations
5+
6+
import agents.tracing
7+
8+
from opentelemetry.instrumentation.genai.openai_agents import (
9+
OpenAIAgentsInstrumentor,
10+
)
11+
from opentelemetry.instrumentation.genai.openai_agents.package import (
12+
_instruments,
13+
)
14+
from opentelemetry.instrumentation.genai.openai_agents.processor import (
15+
GenAITracingProcessor,
16+
)
17+
18+
19+
def _registered_processors() -> tuple:
20+
provider = agents.tracing.get_trace_provider()
21+
multi = getattr(provider, "_multi_processor", None)
22+
return tuple(getattr(multi, "_processors", ()))
23+
24+
25+
def _our_processors():
26+
return [
27+
p
28+
for p in _registered_processors()
29+
if isinstance(p, GenAITracingProcessor)
30+
]
31+
32+
33+
def test_instrumentation_dependencies_exposed() -> None:
34+
instrumentor = OpenAIAgentsInstrumentor()
35+
assert instrumentor.instrumentation_dependencies() == _instruments
36+
37+
38+
def test_instrument_adds_processor_alongside_default() -> None:
39+
instrumentor = OpenAIAgentsInstrumentor()
40+
pre_count = len(_registered_processors())
41+
try:
42+
instrumentor.instrument()
43+
post = _registered_processors()
44+
# Default processor stays in place, ours is appended.
45+
assert len(post) == pre_count + 1
46+
assert len(_our_processors()) == 1
47+
finally:
48+
instrumentor.uninstrument()
49+
assert len(_our_processors()) == 0
50+
51+
52+
def test_instrument_with_disable_openai_trace_export_replaces_processors() -> None:
53+
# Make sure the default processor is registered before we start,
54+
# so the "replace" behavior is observable.
55+
agents.tracing.set_trace_processors(
56+
[agents.tracing.processors.default_processor()]
57+
)
58+
instrumentor = OpenAIAgentsInstrumentor()
59+
try:
60+
instrumentor.instrument(disable_openai_trace_export=True)
61+
post = _registered_processors()
62+
assert len(post) == 1
63+
assert isinstance(post[0], GenAITracingProcessor)
64+
finally:
65+
instrumentor.uninstrument()
66+
67+
68+
def test_double_instrument_is_noop() -> None:
69+
instrumentor = OpenAIAgentsInstrumentor()
70+
try:
71+
instrumentor.instrument()
72+
first = _our_processors()
73+
instrumentor.instrument()
74+
second = _our_processors()
75+
assert len(first) == 1 and len(second) == 1
76+
assert first[0] is second[0]
77+
finally:
78+
instrumentor.uninstrument()
79+
80+
81+
def test_double_uninstrument_is_noop() -> None:
82+
instrumentor = OpenAIAgentsInstrumentor()
83+
instrumentor.instrument()
84+
instrumentor.uninstrument()
85+
instrumentor.uninstrument() # must not raise

0 commit comments

Comments
 (0)