otelzero 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- otelzero/__init__.py +62 -0
- otelzero/__main__.py +140 -0
- otelzero/context.py +86 -0
- otelzero/detection.py +58 -0
- otelzero/hooks/__init__.py +96 -0
- otelzero/hooks/_content.py +865 -0
- otelzero/hooks/_langsmith.py +504 -0
- otelzero/hooks/_native.py +517 -0
- otelzero/hooks/_usage.py +166 -0
- otelzero/hooks/adk.py +128 -0
- otelzero/hooks/base.py +546 -0
- otelzero/hooks/claude_sdk.py +267 -0
- otelzero/hooks/crewai.py +338 -0
- otelzero/hooks/databricks.py +260 -0
- otelzero/hooks/langchain.py +56 -0
- otelzero/hooks/langgraph.py +57 -0
- otelzero/hooks/llama_index.py +724 -0
- otelzero/hooks/microsoft.py +160 -0
- otelzero/hooks/openai_agents.py +313 -0
- otelzero/hooks/pydantic_ai.py +173 -0
- otelzero/hooks/smolagents.py +331 -0
- otelzero/hooks/snowflake.py +271 -0
- otelzero/hooks/strands.py +173 -0
- otelzero/otel.py +372 -0
- otelzero/semconv.py +327 -0
- otelzero/telemetry.py +238 -0
- otelzero/translate/__init__.py +214 -0
- otelzero/translate/langsmith.py +401 -0
- otelzero-0.2.0.dist-info/METADATA +1345 -0
- otelzero-0.2.0.dist-info/RECORD +33 -0
- otelzero-0.2.0.dist-info/WHEEL +5 -0
- otelzero-0.2.0.dist-info/licenses/LICENSE +202 -0
- otelzero-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Microsoft Agent Framework instrumentation: switch on its *own* OpenTelemetry.
|
|
2
|
+
|
|
3
|
+
Microsoft's `Agent Framework <https://github.com/microsoft/agent-framework>`_
|
|
4
|
+
(the ``agent-framework`` package that unifies Semantic Kernel and AutoGen)
|
|
5
|
+
ships OpenTelemetry instrumentation in the GenAI semantic conventions. Each
|
|
6
|
+
``Agent.run`` (streaming or not) gets an ``invoke_agent {name}`` span, with
|
|
7
|
+
``chat {model}`` and ``execute_tool {tool}`` spans from its chat client and
|
|
8
|
+
function-invocation layers. The span carries ``gen_ai.agent.*``,
|
|
9
|
+
``gen_ai.provider.name``, ``gen_ai.tool.definitions``, ``gen_ai.usage.*`` and,
|
|
10
|
+
when sensitive data is enabled, ``gen_ai.input.messages`` /
|
|
11
|
+
``gen_ai.output.messages`` / ``gen_ai.system_instructions``. That is more
|
|
12
|
+
than a wrapper around ``Agent.run`` could see, so this hook wraps nothing.
|
|
13
|
+
Instead it:
|
|
14
|
+
|
|
15
|
+
* configures otelzero's OTLP provider as the global one, which is where
|
|
16
|
+
Agent Framework's tracer sends its spans, so they export to
|
|
17
|
+
``{server_url}/v1/traces``;
|
|
18
|
+
* keeps Agent Framework on the latest GenAI conventions (its default), even
|
|
19
|
+
when ``OTEL_SEMCONV_STABILITY_OPT_IN`` is set for another library;
|
|
20
|
+
* calls ``agent_framework.observability.enable_instrumentation``, with
|
|
21
|
+
``enable_sensitive_data`` following otelzero's content opt-in
|
|
22
|
+
(``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``), so prompts and
|
|
23
|
+
completions are never exported by accident;
|
|
24
|
+
* attaches the shared :class:`~otelzero.hooks._native.NativeRunProcessor`,
|
|
25
|
+
which stamps otelzero's run identity on every Agent Framework span and turns
|
|
26
|
+
each ``invoke_agent`` span into ``agent_start`` / ``agent_end`` /
|
|
27
|
+
``agent_error``.
|
|
28
|
+
|
|
29
|
+
If the application called ``disable_instrumentation()``, that choice is kept,
|
|
30
|
+
and this hook says so instead of overriding it. ``agent_framework`` may be
|
|
31
|
+
imported before or after the install call. The ``otel`` extra is required: the
|
|
32
|
+
lifecycle events come from spans, so tracing is always on whatever
|
|
33
|
+
``OTELZERO_OTEL`` says (it stays on even with ``OTELZERO_OTEL=0``).
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
from typing import Optional
|
|
37
|
+
|
|
38
|
+
from otelzero import otel, semconv
|
|
39
|
+
from otelzero.hooks import _native
|
|
40
|
+
from otelzero.telemetry import TelemetryClient
|
|
41
|
+
|
|
42
|
+
FRAMEWORK = "Microsoft Agent Framework"
|
|
43
|
+
|
|
44
|
+
# Agent Framework's tracer is ``get_tracer("agent_framework")``.
|
|
45
|
+
_SCOPES = ("agent_framework",)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
_LATEST = "gen_ai_latest_experimental"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _keep_latest_conventions(settings) -> None:
|
|
52
|
+
"""Keep Agent Framework on the GenAI conventions above v1.36.
|
|
53
|
+
|
|
54
|
+
That is its default, but only while ``OTEL_SEMCONV_STABILITY_OPT_IN`` is
|
|
55
|
+
unset. A value set for some other library's sake (``http``, say) and
|
|
56
|
+
lacking ``gen_ai_latest_experimental`` would drop it back to the baseline,
|
|
57
|
+
so the token is appended. The framework read the variable when it was
|
|
58
|
+
imported, so the change goes on its settings object rather than the
|
|
59
|
+
environment.
|
|
60
|
+
"""
|
|
61
|
+
current = getattr(settings, "otel_semconv_stability_opt_in", None)
|
|
62
|
+
if not current:
|
|
63
|
+
return
|
|
64
|
+
tokens = [t.strip() for t in current.split(",") if t.strip()]
|
|
65
|
+
if _LATEST not in tokens:
|
|
66
|
+
settings.otel_semconv_stability_opt_in = ",".join([*tokens, _LATEST])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install_microsoft_monitoring(
|
|
70
|
+
server_url: str = "http://localhost:8000",
|
|
71
|
+
user: Optional[str] = None,
|
|
72
|
+
api_key: Optional[str] = None,
|
|
73
|
+
session: Optional[str] = None,
|
|
74
|
+
name: Optional[str] = None,
|
|
75
|
+
) -> TelemetryClient:
|
|
76
|
+
"""
|
|
77
|
+
Install Microsoft Agent Framework monitoring via its native OpenTelemetry.
|
|
78
|
+
|
|
79
|
+
Points Agent Framework's own spans at ``{server_url}/v1/traces`` (or at
|
|
80
|
+
``OTEL_EXPORTER_OTLP_ENDPOINT`` when set), enables its instrumentation,
|
|
81
|
+
and sends ``agent_start`` and ``agent_end`` (or ``agent_error``) for each
|
|
82
|
+
run, built from its ``invoke_agent`` span. May be called before or after
|
|
83
|
+
``agent_framework`` is imported, but requires the ``otel`` extra.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
server_url: URL of the monitoring server
|
|
87
|
+
user: Name of the executing user
|
|
88
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
89
|
+
session: Per-run id used to group events in the app; exported on every
|
|
90
|
+
span as ``gen_ai.conversation.id``
|
|
91
|
+
name: Session title shown on the dashboard and apps. Agent
|
|
92
|
+
Framework's spans carry no prompt when a run starts, so no title is
|
|
93
|
+
derived when this is omitted. Pass it here or per run with
|
|
94
|
+
``run_context(name=...)``.
|
|
95
|
+
|
|
96
|
+
Raises:
|
|
97
|
+
ImportError: ``agent_framework`` is not installed.
|
|
98
|
+
RuntimeError: the OpenTelemetry SDK (the ``otel`` extra) is missing.
|
|
99
|
+
"""
|
|
100
|
+
try:
|
|
101
|
+
from agent_framework import observability
|
|
102
|
+
except ImportError as exc: # pragma: no cover - exercised by hand
|
|
103
|
+
raise ImportError(
|
|
104
|
+
"install_microsoft_monitoring needs agent_framework importable: "
|
|
105
|
+
"uv pip install agent-framework"
|
|
106
|
+
) from exc
|
|
107
|
+
|
|
108
|
+
telemetry = TelemetryClient(
|
|
109
|
+
server_url,
|
|
110
|
+
framework=FRAMEWORK,
|
|
111
|
+
user=user,
|
|
112
|
+
api_key=api_key,
|
|
113
|
+
session=session,
|
|
114
|
+
name=name,
|
|
115
|
+
)
|
|
116
|
+
provider = _native.start_tracing(FRAMEWORK, telemetry, server_url)
|
|
117
|
+
_native.attach_processor(
|
|
118
|
+
_native.global_provider(provider, FRAMEWORK),
|
|
119
|
+
telemetry,
|
|
120
|
+
framework=FRAMEWORK,
|
|
121
|
+
scopes=_SCOPES,
|
|
122
|
+
default_agent_name="Microsoft Agent",
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
observability.enable_instrumentation(
|
|
126
|
+
enable_sensitive_data=semconv.capture_content_enabled()
|
|
127
|
+
)
|
|
128
|
+
settings = getattr(observability, "OBSERVABILITY_SETTINGS", None)
|
|
129
|
+
_keep_latest_conventions(settings)
|
|
130
|
+
if settings is not None and not getattr(settings, "enable_instrumentation", True):
|
|
131
|
+
print(
|
|
132
|
+
f"[MONITOR] {FRAMEWORK}: instrumentation was disabled by the "
|
|
133
|
+
"application (disable_instrumentation()); no runs will be reported."
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
print(
|
|
137
|
+
f"[MONITOR] {FRAMEWORK} monitoring installed via native OpenTelemetry "
|
|
138
|
+
f"(server: {server_url})"
|
|
139
|
+
)
|
|
140
|
+
return telemetry
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def uninstall_microsoft_monitoring() -> None:
|
|
144
|
+
"""Stop stamping identity and sending events; switch the tracing back off.
|
|
145
|
+
|
|
146
|
+
Uses the settings object rather than ``disable_instrumentation()``. That
|
|
147
|
+
call records an explicit user opt-out, which later
|
|
148
|
+
``enable_instrumentation()`` calls (a reinstall included) would respect.
|
|
149
|
+
"""
|
|
150
|
+
from opentelemetry import trace
|
|
151
|
+
|
|
152
|
+
_native.detach(FRAMEWORK, otel._provider)
|
|
153
|
+
_native.detach(FRAMEWORK, trace.get_tracer_provider())
|
|
154
|
+
try:
|
|
155
|
+
from agent_framework import observability
|
|
156
|
+
except ImportError: # pragma: no cover
|
|
157
|
+
return
|
|
158
|
+
settings = getattr(observability, "OBSERVABILITY_SETTINGS", None)
|
|
159
|
+
if settings is not None:
|
|
160
|
+
settings.enable_instrumentation = False
|
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
"""OpenAI Agents SDK instrumentation: wrap the Runner's run chokepoints.
|
|
2
|
+
|
|
3
|
+
The `OpenAI Agents SDK <https://github.com/openai/openai-agents-python>`_ (the
|
|
4
|
+
``openai-agents`` package, OpenAI's production successor to the archived Swarm)
|
|
5
|
+
starts every run through one of three ``Runner`` classmethods:
|
|
6
|
+
|
|
7
|
+
* ``Runner.run(agent, input)`` -- the async entry point,
|
|
8
|
+
* ``Runner.run_sync(agent, input)`` -- the blocking wrapper around it,
|
|
9
|
+
* ``Runner.run_streamed(agent, input)`` -- which returns a ``RunResultStreaming``
|
|
10
|
+
synchronously and delivers the run through its ``stream_events()`` generator.
|
|
11
|
+
|
|
12
|
+
All three are wrapped, because callers use each directly. ``run_sync`` drives
|
|
13
|
+
``run`` on some versions and an internal runner on others, so a nesting guard
|
|
14
|
+
keeps a run that passes through both from being reported twice.
|
|
15
|
+
|
|
16
|
+
With content capture opted in, a run also records its input, the agent's
|
|
17
|
+
instructions when they are a plain string, and its output: every item the run
|
|
18
|
+
produced, so tool calls and their results appear alongside the final answer.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from contextlib import contextmanager
|
|
22
|
+
from typing import Any, Optional
|
|
23
|
+
|
|
24
|
+
import wrapt
|
|
25
|
+
|
|
26
|
+
from otelzero import semconv
|
|
27
|
+
from otelzero.hooks import _content
|
|
28
|
+
from otelzero.hooks._usage import extract_usage
|
|
29
|
+
from otelzero.hooks.base import (
|
|
30
|
+
_ImportInstrumenter,
|
|
31
|
+
install_hook,
|
|
32
|
+
nesting_guard,
|
|
33
|
+
trace_agent_run,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
# ``run_sync`` drives ``run`` on some SDK versions; only the outer call traces.
|
|
37
|
+
_outermost = nesting_guard("openai_agents")
|
|
38
|
+
|
|
39
|
+
# otelzero's own marker so a chokepoint is never wrapped twice.
|
|
40
|
+
_MARKER = "_otelzero_wrapped"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _agents_model_id(agent: Any) -> Optional[str]:
|
|
44
|
+
"""Best-effort model identifier for an OpenAI Agents SDK ``Agent``.
|
|
45
|
+
|
|
46
|
+
``Agent.model`` is ``str | Model | None``: a bare model name, a model object
|
|
47
|
+
whose name hides under one of a couple of attributes, or ``None`` to take the
|
|
48
|
+
runner's default. Falls back to the model class name so a run is never
|
|
49
|
+
labelled with nothing when a model object is set.
|
|
50
|
+
"""
|
|
51
|
+
model = getattr(agent, "model", None)
|
|
52
|
+
if model is None or model == "":
|
|
53
|
+
return None
|
|
54
|
+
if isinstance(model, str):
|
|
55
|
+
return model
|
|
56
|
+
for attr in ("model", "model_name"):
|
|
57
|
+
name = getattr(model, attr, None)
|
|
58
|
+
if isinstance(name, str) and name:
|
|
59
|
+
return name
|
|
60
|
+
return type(model).__name__
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _names(items) -> list:
|
|
64
|
+
"""Readable names for a list of tools or handoff targets."""
|
|
65
|
+
names = []
|
|
66
|
+
for item in items or []:
|
|
67
|
+
for attr in ("name", "agent_name", "tool_name"):
|
|
68
|
+
value = getattr(item, attr, None)
|
|
69
|
+
if isinstance(value, str) and value:
|
|
70
|
+
names.append(value)
|
|
71
|
+
break
|
|
72
|
+
return names
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _agent_details(agent: Any) -> dict:
|
|
76
|
+
"""Summarise the starting agent for a telemetry ``details`` payload."""
|
|
77
|
+
return {
|
|
78
|
+
"model": _agents_model_id(agent),
|
|
79
|
+
"tools": _names(getattr(agent, "tools", None)),
|
|
80
|
+
"handoffs": _names(getattr(agent, "handoffs", None)),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _agents_input_text(args, kwargs) -> Optional[str]:
|
|
85
|
+
"""Best-effort intent text from a run's ``input``, to name the session.
|
|
86
|
+
|
|
87
|
+
``input`` is either the user's prompt as a plain string or a list of input
|
|
88
|
+
items, in which case the last one carries the turn being answered. Items are
|
|
89
|
+
dicts (``{"role", "content"}``) or objects with a ``content`` attribute.
|
|
90
|
+
"""
|
|
91
|
+
# Called through the classmethod, so args is (starting_agent, input, ...).
|
|
92
|
+
value = kwargs.get("input", args[1] if len(args) > 1 else None)
|
|
93
|
+
if isinstance(value, str):
|
|
94
|
+
return value
|
|
95
|
+
if isinstance(value, (list, tuple)) and value:
|
|
96
|
+
last = value[-1]
|
|
97
|
+
content = (
|
|
98
|
+
last.get("content")
|
|
99
|
+
if isinstance(last, dict)
|
|
100
|
+
else getattr(last, "content", None)
|
|
101
|
+
)
|
|
102
|
+
if isinstance(content, str) and content:
|
|
103
|
+
return content
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _output_messages(result: Any) -> Any:
|
|
108
|
+
"""A run's output: the items it produced, else its final output as text.
|
|
109
|
+
|
|
110
|
+
``None`` unless content capture is opted in. ``new_items`` holds each model
|
|
111
|
+
message, tool call and tool result; ``to_input_item`` is the SDK's own
|
|
112
|
+
conversion of one into a Responses API item, which ``messages_from`` reads.
|
|
113
|
+
"""
|
|
114
|
+
if not _content.enabled():
|
|
115
|
+
return None
|
|
116
|
+
try:
|
|
117
|
+
items = [item.to_input_item() for item in getattr(result, "new_items", None) or []]
|
|
118
|
+
messages = _content.messages_from(items, default_role="assistant") if items else None
|
|
119
|
+
except Exception:
|
|
120
|
+
messages = None
|
|
121
|
+
return messages or _content.text_of(getattr(result, "final_output", None))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _result_usage(result: Any) -> Optional[dict]:
|
|
125
|
+
"""Normalized token usage off a ``RunResult`` / ``RunResultStreaming``.
|
|
126
|
+
|
|
127
|
+
The SDK accumulates a run's usage on the shared run context rather than on
|
|
128
|
+
the result itself, so read through ``context_wrapper`` first and fall back to
|
|
129
|
+
a ``usage`` attribute for versions that expose one directly.
|
|
130
|
+
"""
|
|
131
|
+
wrapper = getattr(result, "context_wrapper", None)
|
|
132
|
+
usage = extract_usage(getattr(wrapper, "usage", None))
|
|
133
|
+
if usage is None:
|
|
134
|
+
usage = extract_usage(getattr(result, "usage", None))
|
|
135
|
+
return usage
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class _TracedRunResultStreaming(wrapt.ObjectProxy):
|
|
139
|
+
"""Proxy a ``RunResultStreaming`` to trace its ``stream_events()`` pass.
|
|
140
|
+
|
|
141
|
+
``run_streamed`` returns immediately, and the run is delivered as the caller
|
|
142
|
+
iterates ``stream_events()``. Only that method is overridden -- every other
|
|
143
|
+
attribute (``final_output``, ``new_items``, ``context_wrapper``) reaches the
|
|
144
|
+
real object untouched -- so the span covers exactly the streaming pass, as
|
|
145
|
+
the Microsoft Agent Framework hook does for its ``ResponseStream``.
|
|
146
|
+
"""
|
|
147
|
+
|
|
148
|
+
def __init__(self, wrapped, cm_factory):
|
|
149
|
+
super().__init__(wrapped)
|
|
150
|
+
self._self_cm_factory = cm_factory
|
|
151
|
+
|
|
152
|
+
async def stream_events(self, *args, **kwargs):
|
|
153
|
+
with self._self_cm_factory() as run:
|
|
154
|
+
try:
|
|
155
|
+
async for event in self.__wrapped__.stream_events(*args, **kwargs):
|
|
156
|
+
yield event
|
|
157
|
+
finally:
|
|
158
|
+
# Usage accumulates on the run context as events arrive, so the
|
|
159
|
+
# final read covers the whole stream -- including a consumer that
|
|
160
|
+
# broke out early, whose partial usage is still worth reporting.
|
|
161
|
+
run.set_usage(_result_usage(self.__wrapped__))
|
|
162
|
+
run.set_output(_output_messages(self.__wrapped__))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _wrap_classmethod(cls, name: str, decorator) -> None:
|
|
166
|
+
"""Wrap a classmethod in place, leaving it a classmethod.
|
|
167
|
+
|
|
168
|
+
The decorator goes on the ``classmethod`` object itself, not on its
|
|
169
|
+
``__func__``: wrapt's proxy then *is* the descriptor and binds ``cls`` off
|
|
170
|
+
the arguments itself. Re-wrapping a decorated function as
|
|
171
|
+
``classmethod(deco(fn))`` instead relies on classmethod descriptor
|
|
172
|
+
chaining, which Python 3.13 removed -- there ``cls`` leaks in as the first
|
|
173
|
+
positional argument and every run is attributed to the ``Runner`` class.
|
|
174
|
+
Missing or already-wrapped methods are skipped, which is what makes
|
|
175
|
+
installing this hook twice harmless.
|
|
176
|
+
"""
|
|
177
|
+
raw = cls.__dict__.get(name)
|
|
178
|
+
if raw is None:
|
|
179
|
+
return
|
|
180
|
+
func = raw.__func__ if isinstance(raw, (classmethod, staticmethod)) else raw
|
|
181
|
+
if getattr(func, _MARKER, False):
|
|
182
|
+
return
|
|
183
|
+
wrapped = decorator(raw)
|
|
184
|
+
setattr(func, _MARKER, True)
|
|
185
|
+
setattr(cls, name, wrapped)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class OpenAIAgentsImportHook(_ImportInstrumenter):
|
|
189
|
+
"""Intercept OpenAI Agents SDK imports and instrument ``Runner``.
|
|
190
|
+
|
|
191
|
+
Wraps ``agents.run.Runner``'s ``run``, ``run_sync`` and ``run_streamed`` --
|
|
192
|
+
the three documented ways to start a run -- so each is captured exactly once.
|
|
193
|
+
The SDK's own tracing is unaffected and its spans nest under otelzero's agent
|
|
194
|
+
span (tracing is on by default).
|
|
195
|
+
"""
|
|
196
|
+
|
|
197
|
+
WATCHED = {"agents.run": "_wrap_runner_class"}
|
|
198
|
+
|
|
199
|
+
def _wrap_runner_class(self, module):
|
|
200
|
+
runner = getattr(module, "Runner", None)
|
|
201
|
+
if runner is None:
|
|
202
|
+
return
|
|
203
|
+
|
|
204
|
+
telemetry = self.telemetry
|
|
205
|
+
|
|
206
|
+
@contextmanager
|
|
207
|
+
def _traced(args, kwargs):
|
|
208
|
+
"""Trace one run, recording its prompt as soon as it opens."""
|
|
209
|
+
agent = kwargs.get("starting_agent", args[0] if args else None)
|
|
210
|
+
agent_name = getattr(agent, "name", None) or "OpenAI Agent"
|
|
211
|
+
details = _agent_details(agent)
|
|
212
|
+
with trace_agent_run(
|
|
213
|
+
telemetry,
|
|
214
|
+
semconv.span_name(semconv.OP_INVOKE_AGENT, agent_name),
|
|
215
|
+
{
|
|
216
|
+
semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_AGENT,
|
|
217
|
+
semconv.GEN_AI_AGENT_NAME: agent_name,
|
|
218
|
+
semconv.GEN_AI_REQUEST_MODEL: details["model"],
|
|
219
|
+
semconv.GEN_AI_TOOL_DEFINITIONS: semconv.tool_definitions(
|
|
220
|
+
details["tools"]
|
|
221
|
+
),
|
|
222
|
+
},
|
|
223
|
+
start_event="agent_start",
|
|
224
|
+
end_event="agent_end",
|
|
225
|
+
error_event="agent_error",
|
|
226
|
+
log_fields={
|
|
227
|
+
"agent_name": agent_name,
|
|
228
|
+
"agent_role": agent_name,
|
|
229
|
+
"model": details["model"],
|
|
230
|
+
},
|
|
231
|
+
start_details=details,
|
|
232
|
+
session_name_hint=_agents_input_text(args, kwargs),
|
|
233
|
+
) as run:
|
|
234
|
+
run.set_input(
|
|
235
|
+
_content.messages_from(
|
|
236
|
+
kwargs.get("input", args[1] if len(args) > 1 else None)
|
|
237
|
+
)
|
|
238
|
+
)
|
|
239
|
+
instructions = getattr(agent, "instructions", None)
|
|
240
|
+
if isinstance(instructions, str):
|
|
241
|
+
run.set_system(instructions)
|
|
242
|
+
yield run
|
|
243
|
+
|
|
244
|
+
@wrapt.decorator
|
|
245
|
+
async def run_wrapper(wrapped, instance, args, kwargs):
|
|
246
|
+
with _outermost() as outermost:
|
|
247
|
+
if not outermost:
|
|
248
|
+
return await wrapped(*args, **kwargs)
|
|
249
|
+
with _traced(args, kwargs) as run:
|
|
250
|
+
result = await wrapped(*args, **kwargs)
|
|
251
|
+
run.set_usage(_result_usage(result))
|
|
252
|
+
run.set_output(_output_messages(result))
|
|
253
|
+
return result
|
|
254
|
+
|
|
255
|
+
@wrapt.decorator
|
|
256
|
+
def run_sync_wrapper(wrapped, instance, args, kwargs):
|
|
257
|
+
with _outermost() as outermost:
|
|
258
|
+
if not outermost:
|
|
259
|
+
return wrapped(*args, **kwargs)
|
|
260
|
+
with _traced(args, kwargs) as run:
|
|
261
|
+
result = wrapped(*args, **kwargs)
|
|
262
|
+
run.set_usage(_result_usage(result))
|
|
263
|
+
run.set_output(_output_messages(result))
|
|
264
|
+
return result
|
|
265
|
+
|
|
266
|
+
@wrapt.decorator
|
|
267
|
+
def run_streamed_wrapper(wrapped, instance, args, kwargs):
|
|
268
|
+
result = wrapped(*args, **kwargs)
|
|
269
|
+
# ``run_streamed`` is synchronous: it hands back the streaming result
|
|
270
|
+
# and the run is delivered as the caller iterates it, so the span is
|
|
271
|
+
# opened there rather than here.
|
|
272
|
+
return _TracedRunResultStreaming(result, lambda: _traced(args, kwargs))
|
|
273
|
+
|
|
274
|
+
for method_name, decorator in (
|
|
275
|
+
("run", run_wrapper),
|
|
276
|
+
("run_sync", run_sync_wrapper),
|
|
277
|
+
("run_streamed", run_streamed_wrapper),
|
|
278
|
+
):
|
|
279
|
+
_wrap_classmethod(runner, method_name, decorator)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def install_openai_agents_monitoring(
|
|
283
|
+
server_url: str = "http://localhost:8000",
|
|
284
|
+
user: Optional[str] = None,
|
|
285
|
+
api_key: Optional[str] = None,
|
|
286
|
+
session: Optional[str] = None,
|
|
287
|
+
name: Optional[str] = None,
|
|
288
|
+
):
|
|
289
|
+
"""
|
|
290
|
+
Install OpenAI Agents SDK monitoring instrumentation.
|
|
291
|
+
|
|
292
|
+
Instruments ``agents.run.Runner.run``, ``Runner.run_sync`` and
|
|
293
|
+
``Runner.run_streamed`` -- the three documented ways to start a run -- so
|
|
294
|
+
every run streams start/end/error telemetry with zero changes to the agent
|
|
295
|
+
code. Call this *before* ``agents`` is imported.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
server_url: URL of the monitoring server
|
|
299
|
+
user: Name of the executing user
|
|
300
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
301
|
+
session: Per-run id used to group events in the app
|
|
302
|
+
name: Explicit session title (else derived from the run's input)
|
|
303
|
+
"""
|
|
304
|
+
return install_hook(
|
|
305
|
+
OpenAIAgentsImportHook,
|
|
306
|
+
framework="OpenAI Agents",
|
|
307
|
+
label="OpenAI Agents SDK",
|
|
308
|
+
server_url=server_url,
|
|
309
|
+
user=user,
|
|
310
|
+
api_key=api_key,
|
|
311
|
+
session=session,
|
|
312
|
+
name=name,
|
|
313
|
+
)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Pydantic AI instrumentation: switch on the framework's *own* OpenTelemetry.
|
|
2
|
+
|
|
3
|
+
`Pydantic AI <https://ai.pydantic.dev>`_ ships first-party OpenTelemetry
|
|
4
|
+
instrumentation (``Agent.instrument_all`` / ``InstrumentationSettings``) that
|
|
5
|
+
already follows the GenAI semantic conventions: one ``invoke_agent {name}`` span
|
|
6
|
+
per run with ``chat`` and ``execute_tool`` children, ``gen_ai.*`` attributes and
|
|
7
|
+
Opt-In message content. That is richer than anything a wrapper around
|
|
8
|
+
``Agent.run`` can see from the outside, so this hook does not wrap anything.
|
|
9
|
+
Instead it points the native instrumentation at the registry's OTLP receiver and
|
|
10
|
+
adds one span processor that
|
|
11
|
+
|
|
12
|
+
* stamps otelzero's run identity -- ``gen_ai.conversation.id``, ``user.name``
|
|
13
|
+
and ``otelzero.session.name`` -- onto every span, and
|
|
14
|
+
* turns each native ``invoke_agent`` span into the same ``agent_start`` /
|
|
15
|
+
``agent_end`` / ``agent_error`` HTTP events the wrapping hooks send, so the
|
|
16
|
+
dashboard's event feed is unchanged.
|
|
17
|
+
|
|
18
|
+
The processor is shared with the other native-OTel hooks (Strands, Agent
|
|
19
|
+
Framework, ADK); see :mod:`otelzero.hooks._native`.
|
|
20
|
+
|
|
21
|
+
Compared to the import-hook frameworks there is no ``sys.meta_path`` finder --
|
|
22
|
+
``pydantic_ai`` is imported eagerly, so it may be imported before or after the
|
|
23
|
+
install call -- and the ``otel`` extra is required: the lifecycle events are
|
|
24
|
+
derived from spans, so tracing stays on even with ``OTELZERO_OTEL=0``.
|
|
25
|
+
|
|
26
|
+
Pydantic AI sets its own ``gen_ai.conversation.id`` -- a fresh id per run, since
|
|
27
|
+
nothing ties two runs together for it. otelzero's session is exactly that tie,
|
|
28
|
+
so the processor overwrites it with the session id (from
|
|
29
|
+
:func:`otelzero.context.run_context` when one is active, else the client's).
|
|
30
|
+
|
|
31
|
+
What the events can carry is bounded by what Pydantic AI puts on its spans: the
|
|
32
|
+
tool list and token usage are known only when the run ends, and an
|
|
33
|
+
``agent_error`` names the exception *type* unless content capture is on, which
|
|
34
|
+
is when Pydantic AI also records the message and traceback.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from typing import Any, Optional
|
|
38
|
+
|
|
39
|
+
from otelzero import otel, semconv
|
|
40
|
+
from otelzero.hooks import _native
|
|
41
|
+
from otelzero.telemetry import TelemetryClient
|
|
42
|
+
|
|
43
|
+
FRAMEWORK = "Pydantic AI"
|
|
44
|
+
|
|
45
|
+
# The provider attribute holding this hook's processor (see
|
|
46
|
+
# :func:`otelzero.hooks._native.attach_processor`).
|
|
47
|
+
_PROCESSOR_MARKER = _native.marker(FRAMEWORK)
|
|
48
|
+
|
|
49
|
+
# Pydantic AI's tracer is named after the package.
|
|
50
|
+
_SCOPES = ("pydantic-ai", "pydantic_ai")
|
|
51
|
+
|
|
52
|
+
# Kept under their old names: the span parsing is now shared with the other
|
|
53
|
+
# native-OTel hooks.
|
|
54
|
+
_usage_from = _native.usage_from
|
|
55
|
+
_tool_names = _native.tool_names
|
|
56
|
+
_exception = _native.exception_of
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _native_settings(provider: Any, include_content: bool) -> Any:
|
|
60
|
+
"""Build Pydantic AI's ``InstrumentationSettings`` against our provider.
|
|
61
|
+
|
|
62
|
+
``include_content`` follows the same opt-in as every other hook
|
|
63
|
+
(``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``), so prompts and completions are never
|
|
64
|
+
exported by accident. Binary content stays off regardless: an image in a
|
|
65
|
+
prompt would otherwise be base64-encoded into a span attribute. ``version``
|
|
66
|
+
is pinned so an upgrade of Pydantic AI cannot silently change the span
|
|
67
|
+
shape the dashboard parses.
|
|
68
|
+
"""
|
|
69
|
+
from pydantic_ai.models.instrumented import InstrumentationSettings
|
|
70
|
+
|
|
71
|
+
return InstrumentationSettings(
|
|
72
|
+
tracer_provider=provider,
|
|
73
|
+
include_content=include_content,
|
|
74
|
+
include_binary_content=False,
|
|
75
|
+
version=5,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def install_pydantic_ai_monitoring(
|
|
80
|
+
server_url: str = "http://localhost:8000",
|
|
81
|
+
user: Optional[str] = None,
|
|
82
|
+
api_key: Optional[str] = None,
|
|
83
|
+
session: Optional[str] = None,
|
|
84
|
+
name: Optional[str] = None,
|
|
85
|
+
*,
|
|
86
|
+
settings: Any = None,
|
|
87
|
+
) -> TelemetryClient:
|
|
88
|
+
"""
|
|
89
|
+
Install Pydantic AI monitoring via the framework's native OpenTelemetry.
|
|
90
|
+
|
|
91
|
+
Configures a tracer provider exporting OTLP/HTTP to ``{server_url}/v1/traces``
|
|
92
|
+
(or to ``OTEL_EXPORTER_OTLP_ENDPOINT`` when set), then calls
|
|
93
|
+
``Agent.instrument_all`` so every agent -- existing or created later --
|
|
94
|
+
emits Pydantic AI's own ``invoke_agent`` / ``chat`` / ``execute_tool`` spans
|
|
95
|
+
through it. Each run also sends ``agent_start`` and ``agent_end`` (or
|
|
96
|
+
``agent_error``) to the server, built from those spans. Unlike the
|
|
97
|
+
import-hook frameworks, this may be called before or after ``pydantic_ai``
|
|
98
|
+
is imported, but it requires the ``otel`` extra.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
server_url: URL of the monitoring server
|
|
102
|
+
user: Name of the executing user
|
|
103
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
104
|
+
session: Per-run id used to group events in the app; exported on every
|
|
105
|
+
span as ``gen_ai.conversation.id``
|
|
106
|
+
name: Session title shown on the dashboard and apps; sent on every
|
|
107
|
+
event and exported on every span as ``otelzero.session.name``.
|
|
108
|
+
Pydantic AI's spans carry no prompt at run start, so unlike the
|
|
109
|
+
wrapping hooks no title is derived when this is omitted --
|
|
110
|
+
pass it here or per run via ``run_context(name=...)``.
|
|
111
|
+
settings: A ready-made ``InstrumentationSettings`` to pass through
|
|
112
|
+
unchanged, for callers who want a different format ``version`` or
|
|
113
|
+
content policy. Its ``tracer_provider`` should be left unset so it
|
|
114
|
+
exports through the provider configured here.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
ImportError: ``pydantic_ai`` is not installed.
|
|
118
|
+
RuntimeError: the OpenTelemetry SDK (the ``otel`` extra) is missing,
|
|
119
|
+
which leaves this hook with no way to report anything.
|
|
120
|
+
"""
|
|
121
|
+
try:
|
|
122
|
+
from pydantic_ai import Agent
|
|
123
|
+
except ImportError as exc: # pragma: no cover - exercised by hand
|
|
124
|
+
raise ImportError(
|
|
125
|
+
"install_pydantic_ai_monitoring needs pydantic_ai importable: "
|
|
126
|
+
"uv pip install pydantic-ai"
|
|
127
|
+
) from exc
|
|
128
|
+
|
|
129
|
+
# The home of the run identity (user / session / title, honouring
|
|
130
|
+
# run_context), of framework-version detection, and of the HTTP channel the
|
|
131
|
+
# lifecycle events go out on.
|
|
132
|
+
telemetry = TelemetryClient(
|
|
133
|
+
server_url,
|
|
134
|
+
framework=FRAMEWORK,
|
|
135
|
+
user=user,
|
|
136
|
+
api_key=api_key,
|
|
137
|
+
session=session,
|
|
138
|
+
name=name,
|
|
139
|
+
)
|
|
140
|
+
provider = _native.start_tracing(FRAMEWORK, telemetry, server_url)
|
|
141
|
+
# Pydantic AI is handed this provider explicitly (below), so the processor
|
|
142
|
+
# goes on it rather than on whatever provider is global.
|
|
143
|
+
_native.attach_processor(
|
|
144
|
+
provider,
|
|
145
|
+
telemetry,
|
|
146
|
+
framework=FRAMEWORK,
|
|
147
|
+
scopes=_SCOPES,
|
|
148
|
+
default_agent_name="Pydantic AI Agent",
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
if settings is None:
|
|
152
|
+
settings = _native_settings(provider, semconv.capture_content_enabled())
|
|
153
|
+
Agent.instrument_all(settings)
|
|
154
|
+
|
|
155
|
+
print(
|
|
156
|
+
f"[MONITOR] {FRAMEWORK} monitoring installed via native OpenTelemetry "
|
|
157
|
+
f"(server: {server_url})"
|
|
158
|
+
)
|
|
159
|
+
return telemetry
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def uninstall_pydantic_ai_monitoring() -> None:
|
|
163
|
+
"""Stop stamping identity and sending events; switch Pydantic AI's tracing off.
|
|
164
|
+
|
|
165
|
+
The provider keeps exporting (a provider cannot drop a processor, and other
|
|
166
|
+
hooks may share it); only this hook's contributions stop.
|
|
167
|
+
"""
|
|
168
|
+
_native.detach(FRAMEWORK, otel._provider)
|
|
169
|
+
try:
|
|
170
|
+
from pydantic_ai import Agent
|
|
171
|
+
except ImportError: # pragma: no cover
|
|
172
|
+
return
|
|
173
|
+
Agent.instrument_all(False)
|