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.
@@ -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)