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
otelzero/hooks/adk.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Google ADK instrumentation: switch on the framework's *own* OpenTelemetry.
|
|
2
|
+
|
|
3
|
+
`Google ADK <https://github.com/google/adk-python>`_ always traces itself
|
|
4
|
+
through the global OpenTelemetry tracer provider, in the GenAI semantic
|
|
5
|
+
conventions. A ``Runner`` run opens an ``invoke_workflow {app}`` span. Each
|
|
6
|
+
agent that takes a turn gets an ``invoke_agent {name}`` span under it, with
|
|
7
|
+
``generate_content {model}`` and ``execute_tool {tool}`` spans nested inside.
|
|
8
|
+
That is more than a wrapper around ``Runner.run_async`` could see, so this hook
|
|
9
|
+
wraps nothing. Instead it:
|
|
10
|
+
|
|
11
|
+
* configures otelzero's OTLP provider as the global one, so ADK's spans export
|
|
12
|
+
to ``{server_url}/v1/traces``;
|
|
13
|
+
* opts ADK into the latest GenAI conventions (``gen_ai_latest_experimental``
|
|
14
|
+
in ``OTEL_SEMCONV_STABILITY_OPT_IN``), which replace ADK's legacy
|
|
15
|
+
``gcp.vertex.agent.*`` payload attributes with ``gen_ai.input.messages`` /
|
|
16
|
+
``gen_ai.output.messages`` / ``gen_ai.tool.call.*``;
|
|
17
|
+
* attaches the shared :class:`~otelzero.hooks._native.NativeRunProcessor`,
|
|
18
|
+
which stamps ``user.name`` and ``otelzero.session.name`` on every ADK span
|
|
19
|
+
and turns each ``invoke_agent`` span into ``agent_start`` / ``agent_end`` /
|
|
20
|
+
``agent_error``. A multi-agent run reports each agent that takes a turn.
|
|
21
|
+
|
|
22
|
+
**Session.** ADK writes its own session id as ``gen_ai.conversation.id`` on
|
|
23
|
+
its spans, and it does so after each span starts, so the id can't be replaced
|
|
24
|
+
there. It is also the right value: an ADK session *is* the conversation, and
|
|
25
|
+
the caller chooses it. So for ADK the dashboard groups runs by ADK's session
|
|
26
|
+
id. The events are filed under it too, so spans and events line up, and the
|
|
27
|
+
``session=`` argument and ``run_context(conversation_id=...)`` don't apply.
|
|
28
|
+
|
|
29
|
+
**Content.** ADK follows the standard
|
|
30
|
+
``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` variable on its own
|
|
31
|
+
terms. ``true`` / ``1`` mean ``EVENT_ONLY`` there, so prompts go to log records,
|
|
32
|
+
not spans. ``SPAN_ONLY`` or ``SPAN_AND_EVENT`` puts them on span attributes,
|
|
33
|
+
where the registry reads them. Unset, no content is recorded.
|
|
34
|
+
|
|
35
|
+
``google.adk`` may be imported before or after the install call. The ``otel``
|
|
36
|
+
extra is required: the lifecycle events come from spans, so tracing is always
|
|
37
|
+
on even with ``OTELZERO_OTEL=0``.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from typing import Optional
|
|
41
|
+
|
|
42
|
+
from otelzero import otel
|
|
43
|
+
from otelzero.hooks import _native
|
|
44
|
+
from otelzero.telemetry import TelemetryClient
|
|
45
|
+
|
|
46
|
+
FRAMEWORK = "Google ADK"
|
|
47
|
+
|
|
48
|
+
# ADK's tracer is ``trace.get_tracer("gcp.vertex.agent")``.
|
|
49
|
+
_SCOPES = ("gcp.vertex.agent", "google.adk")
|
|
50
|
+
|
|
51
|
+
_LATEST = "gen_ai_latest_experimental"
|
|
52
|
+
|
|
53
|
+
def install_adk_monitoring(
|
|
54
|
+
server_url: str = "http://localhost:8000",
|
|
55
|
+
user: Optional[str] = None,
|
|
56
|
+
api_key: Optional[str] = None,
|
|
57
|
+
session: Optional[str] = None,
|
|
58
|
+
name: Optional[str] = None,
|
|
59
|
+
) -> TelemetryClient:
|
|
60
|
+
"""
|
|
61
|
+
Install Google ADK monitoring via the framework's native OpenTelemetry.
|
|
62
|
+
|
|
63
|
+
Points ADK's own spans at ``{server_url}/v1/traces`` (or at
|
|
64
|
+
``OTEL_EXPORTER_OTLP_ENDPOINT`` when set), opts them into the latest GenAI
|
|
65
|
+
semantic conventions, and sends ``agent_start`` and ``agent_end`` (or
|
|
66
|
+
``agent_error``) for each agent turn, built from its ``invoke_agent``
|
|
67
|
+
span. May be called before or after ``google.adk`` is imported, but
|
|
68
|
+
requires the ``otel`` extra.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
server_url: URL of the monitoring server
|
|
72
|
+
user: Name of the executing user
|
|
73
|
+
api_key: Ingest key tying events to an owner (for the iOS app)
|
|
74
|
+
session: Fallback session id, used only for a run whose spans carry
|
|
75
|
+
no ADK session id; ADK's own session id groups runs otherwise
|
|
76
|
+
name: Session title shown on the dashboard and apps (no title is
|
|
77
|
+
derived from the prompt)
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ImportError: ``google.adk`` is not installed.
|
|
81
|
+
RuntimeError: the OpenTelemetry SDK (the ``otel`` extra) is missing.
|
|
82
|
+
"""
|
|
83
|
+
try:
|
|
84
|
+
import google.adk # noqa: F401
|
|
85
|
+
except ImportError as exc: # pragma: no cover - exercised by hand
|
|
86
|
+
raise ImportError(
|
|
87
|
+
"install_adk_monitoring needs google.adk importable: "
|
|
88
|
+
"uv pip install google-adk"
|
|
89
|
+
) from exc
|
|
90
|
+
|
|
91
|
+
telemetry = TelemetryClient(
|
|
92
|
+
server_url,
|
|
93
|
+
framework=FRAMEWORK,
|
|
94
|
+
user=user,
|
|
95
|
+
api_key=api_key,
|
|
96
|
+
session=session,
|
|
97
|
+
name=name,
|
|
98
|
+
)
|
|
99
|
+
provider = _native.start_tracing(FRAMEWORK, telemetry, server_url)
|
|
100
|
+
_native.attach_processor(
|
|
101
|
+
_native.global_provider(provider, FRAMEWORK),
|
|
102
|
+
telemetry,
|
|
103
|
+
framework=FRAMEWORK,
|
|
104
|
+
scopes=_SCOPES,
|
|
105
|
+
default_agent_name="ADK Agent",
|
|
106
|
+
native_conversation_id=True,
|
|
107
|
+
)
|
|
108
|
+
# ADK reads the variable per invocation, so no refresh is needed.
|
|
109
|
+
_native.opt_in_tokens(FRAMEWORK, [_LATEST])
|
|
110
|
+
|
|
111
|
+
print(
|
|
112
|
+
f"[MONITOR] {FRAMEWORK} monitoring installed via native OpenTelemetry "
|
|
113
|
+
f"(server: {server_url})"
|
|
114
|
+
)
|
|
115
|
+
return telemetry
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def uninstall_adk_monitoring() -> None:
|
|
119
|
+
"""Stop stamping identity and sending events; drop the opt-in tokens it added.
|
|
120
|
+
|
|
121
|
+
ADK keeps tracing through the global provider, since that's how it always
|
|
122
|
+
works. Only this hook's contributions stop.
|
|
123
|
+
"""
|
|
124
|
+
from opentelemetry import trace
|
|
125
|
+
|
|
126
|
+
_native.detach(FRAMEWORK, otel._provider)
|
|
127
|
+
_native.detach(FRAMEWORK, trace.get_tracer_provider())
|
|
128
|
+
_native.release_opt_in(FRAMEWORK)
|
otelzero/hooks/base.py
ADDED
|
@@ -0,0 +1,546 @@
|
|
|
1
|
+
"""Shared import-hook machinery and telemetry helpers for framework hooks.
|
|
2
|
+
|
|
3
|
+
This module holds everything the per-framework hooks (``crewai``, ``langchain``,
|
|
4
|
+
``strands``, ``adk``, ``databricks``) build on:
|
|
5
|
+
|
|
6
|
+
* :class:`_ImportInstrumenter` / :class:`_InstrumentingLoader` -- the
|
|
7
|
+
``sys.meta_path`` plumbing that instruments a watched module right after it is
|
|
8
|
+
imported. Subclasses only declare *which* modules to watch and *how* to wrap
|
|
9
|
+
them.
|
|
10
|
+
* :func:`trace_agent_run` -- the span + start/end/error telemetry pattern every
|
|
11
|
+
``_wrap_*`` method shares, factored into one context manager.
|
|
12
|
+
* :func:`start_agent_run` / :class:`AgentRun` -- the same pattern for a framework
|
|
13
|
+
whose run entry point returns *before* the run finishes, so the terminal event
|
|
14
|
+
is emitted from wherever the run actually ends.
|
|
15
|
+
* :func:`install_hook` -- the ``TelemetryClient`` + register boilerplate every
|
|
16
|
+
``install_*_monitoring`` entry point shares.
|
|
17
|
+
* :func:`nesting_guard` -- the re-entrancy guard hooks need when a framework's
|
|
18
|
+
public entry points call each other, or nest a run inside a run.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import contextvars
|
|
22
|
+
import importlib.abc
|
|
23
|
+
import importlib.util
|
|
24
|
+
import sys
|
|
25
|
+
import traceback as tb
|
|
26
|
+
from contextlib import contextmanager
|
|
27
|
+
from typing import Dict, Optional
|
|
28
|
+
|
|
29
|
+
from otelzero import otel, semconv
|
|
30
|
+
from otelzero.hooks import _content
|
|
31
|
+
from otelzero.telemetry import TelemetryClient
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _InstrumentingLoader(importlib.abc.Loader):
|
|
35
|
+
"""Defer to the real loader, then instrument the module once it executes."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, loader, hook, fullname):
|
|
38
|
+
self._loader = loader
|
|
39
|
+
self._hook = hook
|
|
40
|
+
self._fullname = fullname
|
|
41
|
+
|
|
42
|
+
def create_module(self, spec):
|
|
43
|
+
return self._loader.create_module(spec)
|
|
44
|
+
|
|
45
|
+
def exec_module(self, module):
|
|
46
|
+
self._loader.exec_module(module)
|
|
47
|
+
self._hook._instrument_once(self._fullname, module)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _ImportInstrumenter(importlib.abc.MetaPathFinder):
|
|
51
|
+
"""Base meta-path finder that instruments watched modules after import.
|
|
52
|
+
|
|
53
|
+
Uses the modern ``find_spec`` protocol (Python >= 3.12; the legacy
|
|
54
|
+
``find_module`` protocol was removed in 3.12). It resolves the *real* spec
|
|
55
|
+
from the rest of ``sys.meta_path`` -- guarding against re-entrancy -- and wraps
|
|
56
|
+
its loader so the module is instrumented immediately after it executes.
|
|
57
|
+
|
|
58
|
+
Subclasses set :attr:`WATCHED`, a mapping of watched module name to the name
|
|
59
|
+
of the ``_wrap_*`` method that instruments it.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# module fullname -> name of the instance method that instruments it.
|
|
63
|
+
WATCHED: Dict[str, str] = {}
|
|
64
|
+
|
|
65
|
+
def __init__(self, telemetry_client: "TelemetryClient"):
|
|
66
|
+
self.telemetry = telemetry_client
|
|
67
|
+
self.instrumented_modules = set()
|
|
68
|
+
self._finding = set()
|
|
69
|
+
|
|
70
|
+
def _watches(self, fullname: str) -> bool:
|
|
71
|
+
return fullname in self.WATCHED
|
|
72
|
+
|
|
73
|
+
def _instrument_module(self, fullname, module):
|
|
74
|
+
getattr(self, self.WATCHED[fullname])(module)
|
|
75
|
+
|
|
76
|
+
def find_spec(self, fullname, path=None, target=None):
|
|
77
|
+
if not self._watches(fullname) or fullname in self._finding:
|
|
78
|
+
return None
|
|
79
|
+
# Re-enter the import machinery to find the real spec, guarding this
|
|
80
|
+
# name so our own finder is skipped on the recursive lookup.
|
|
81
|
+
self._finding.add(fullname)
|
|
82
|
+
try:
|
|
83
|
+
spec = importlib.util.find_spec(fullname)
|
|
84
|
+
except ImportError:
|
|
85
|
+
return None
|
|
86
|
+
finally:
|
|
87
|
+
self._finding.discard(fullname)
|
|
88
|
+
if spec is None or spec.loader is None:
|
|
89
|
+
return None
|
|
90
|
+
spec.loader = _InstrumentingLoader(spec.loader, self, fullname)
|
|
91
|
+
return spec
|
|
92
|
+
|
|
93
|
+
def _instrument_once(self, fullname, module):
|
|
94
|
+
if fullname not in self.instrumented_modules:
|
|
95
|
+
self._instrument_module(fullname, module)
|
|
96
|
+
self.instrumented_modules.add(fullname)
|
|
97
|
+
print(f"[INSTRUMENT] Instrumented {fullname}")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class RunContext:
|
|
101
|
+
"""Mutable handle a wrapper uses to attach data discovered during the run.
|
|
102
|
+
|
|
103
|
+
``trace_agent_run`` yields one of these; a hook that finds token usage on
|
|
104
|
+
the framework's response (or accumulates it across a stream) calls
|
|
105
|
+
:meth:`set_usage` so the usage lands on the terminal telemetry event and
|
|
106
|
+
the OTel span. :meth:`set_model` does the same for the model id when the
|
|
107
|
+
framework only names it in the response.
|
|
108
|
+
|
|
109
|
+
:meth:`set_input` / :meth:`set_output` / :meth:`set_system` do the same for
|
|
110
|
+
the run's prompt, response and system prompt, which land as the Opt-In
|
|
111
|
+
``gen_ai.input.messages`` / ``gen_ai.output.messages`` /
|
|
112
|
+
``gen_ai.system_instructions`` span attributes and as ``input_messages`` /
|
|
113
|
+
``output_messages`` / ``system_instructions`` in the terminal event's
|
|
114
|
+
``details``. All three are no-ops unless the user opted in to content
|
|
115
|
+
capture (see :mod:`otelzero.hooks._content`).
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
__slots__ = (
|
|
119
|
+
"span",
|
|
120
|
+
"usage",
|
|
121
|
+
"model",
|
|
122
|
+
"input_messages",
|
|
123
|
+
"output_messages",
|
|
124
|
+
"system_instructions",
|
|
125
|
+
"finish_reasons",
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def __init__(self, span):
|
|
129
|
+
self.span = span
|
|
130
|
+
self.usage: Optional[dict] = None
|
|
131
|
+
self.model: Optional[str] = None
|
|
132
|
+
self.input_messages: Optional[list] = None
|
|
133
|
+
self.output_messages: Optional[list] = None
|
|
134
|
+
self.system_instructions: Optional[list] = None
|
|
135
|
+
self.finish_reasons: Optional[list] = None
|
|
136
|
+
|
|
137
|
+
def set_usage(self, usage: Optional[dict]) -> None:
|
|
138
|
+
if usage:
|
|
139
|
+
self.usage = usage
|
|
140
|
+
|
|
141
|
+
def set_model(self, model: Optional[str]) -> None:
|
|
142
|
+
"""Name the model a run turned out to use.
|
|
143
|
+
|
|
144
|
+
For hooks that cannot read the model off the agent before the run: a
|
|
145
|
+
LangGraph node closes over its chat model, so the graph exposes nothing
|
|
146
|
+
to read, but every reply message says which model produced it. The first
|
|
147
|
+
model reported wins, so one run mixing models is labelled by its first.
|
|
148
|
+
"""
|
|
149
|
+
if model and not self.model:
|
|
150
|
+
self.model = model
|
|
151
|
+
|
|
152
|
+
def set_input(self, messages) -> None:
|
|
153
|
+
"""Record the prompt: a shaped message list or plain text."""
|
|
154
|
+
messages = _content.as_messages(messages, "user")
|
|
155
|
+
if messages:
|
|
156
|
+
self.input_messages = messages
|
|
157
|
+
|
|
158
|
+
def set_output(self, messages, finish_reasons=None) -> None:
|
|
159
|
+
"""Record the response: a shaped message list or plain text.
|
|
160
|
+
|
|
161
|
+
``finish_reasons`` -- one normalised reason (see
|
|
162
|
+
``_content.finish_reason``) or a list of them, one per generation the
|
|
163
|
+
framework reported -- becomes ``gen_ai.response.finish_reasons``, where
|
|
164
|
+
the conventions want it; the field an output message used to carry is
|
|
165
|
+
deprecated. It is recorded whether or not content capture is opted in,
|
|
166
|
+
and left unset when the framework named no reason.
|
|
167
|
+
"""
|
|
168
|
+
if isinstance(finish_reasons, str):
|
|
169
|
+
finish_reasons = [finish_reasons]
|
|
170
|
+
if finish_reasons:
|
|
171
|
+
self.finish_reasons = [r for r in finish_reasons if r]
|
|
172
|
+
messages = _content.as_messages(messages, "assistant")
|
|
173
|
+
if messages:
|
|
174
|
+
self.output_messages = list(messages)
|
|
175
|
+
|
|
176
|
+
def set_system(self, parts) -> None:
|
|
177
|
+
"""Record the system prompt: a shaped part list or plain text."""
|
|
178
|
+
parts = _content.as_parts(parts)
|
|
179
|
+
if parts:
|
|
180
|
+
self.system_instructions = parts
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _with_identity(telemetry: "TelemetryClient", attributes: dict) -> dict:
|
|
184
|
+
"""Stamp a span's attributes with who is running it and which flow it is.
|
|
185
|
+
|
|
186
|
+
Every hook opens its span through :func:`trace_agent_run`, so doing this in
|
|
187
|
+
one place keeps ``gen_ai.conversation.id`` and ``user.name`` on every otelzero
|
|
188
|
+
span across all frameworks, plus ``otelzero.session.name`` once the session
|
|
189
|
+
has a title. They are span-level rather than resource-level because one
|
|
190
|
+
process can serve many users and many concurrent flows.
|
|
191
|
+
"""
|
|
192
|
+
user, session, session_name = telemetry.run_identity()
|
|
193
|
+
attrs = dict(attributes or {})
|
|
194
|
+
if session:
|
|
195
|
+
attrs[semconv.GEN_AI_CONVERSATION_ID] = session
|
|
196
|
+
if session_name:
|
|
197
|
+
attrs[semconv.OTELZERO_SESSION_NAME] = session_name
|
|
198
|
+
if user:
|
|
199
|
+
attrs[semconv.USER_NAME] = user
|
|
200
|
+
return attrs
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _merge_run_details(details: Optional[dict], ctx: RunContext) -> Optional[dict]:
|
|
204
|
+
"""The terminal event's ``details``: the hook's own plus what the run found.
|
|
205
|
+
|
|
206
|
+
Token usage and the run's finish reasons always ride along; message content
|
|
207
|
+
only when the user opted in -- the same switch that gates the span
|
|
208
|
+
attributes, so the HTTP and OTLP paths never disagree about what was
|
|
209
|
+
recorded.
|
|
210
|
+
"""
|
|
211
|
+
extra = {}
|
|
212
|
+
if ctx.usage:
|
|
213
|
+
extra["usage"] = ctx.usage
|
|
214
|
+
if ctx.finish_reasons:
|
|
215
|
+
extra["finish_reasons"] = list(ctx.finish_reasons)
|
|
216
|
+
if semconv.capture_content_enabled():
|
|
217
|
+
for key in ("input_messages", "output_messages", "system_instructions"):
|
|
218
|
+
value = getattr(ctx, key)
|
|
219
|
+
if value:
|
|
220
|
+
extra[key] = value
|
|
221
|
+
if not extra:
|
|
222
|
+
return details
|
|
223
|
+
return {**(details or {}), **extra}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _close_span(span, ctx: RunContext) -> None:
|
|
227
|
+
"""Stamp everything the run discovered onto its span before it ends."""
|
|
228
|
+
otel.set_usage(span, ctx.usage)
|
|
229
|
+
otel.set_finish_reasons(span, semconv.finish_reasons(ctx.finish_reasons))
|
|
230
|
+
otel.set_messages(
|
|
231
|
+
span,
|
|
232
|
+
semconv.input_messages(ctx.input_messages),
|
|
233
|
+
semconv.output_messages(ctx.output_messages),
|
|
234
|
+
semconv.system_instructions(ctx.system_instructions),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _with_model(fields: dict, ctx: "RunContext") -> dict:
|
|
239
|
+
"""Add a late-discovered model to a terminal event's log fields.
|
|
240
|
+
|
|
241
|
+
A model the hook already knew at ``*_start`` wins: it is what the run asked
|
|
242
|
+
for, whereas this one is what the response happened to report.
|
|
243
|
+
"""
|
|
244
|
+
if not ctx.model or fields.get("model"):
|
|
245
|
+
return fields
|
|
246
|
+
return {**fields, "model": ctx.model}
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _close_run(
|
|
250
|
+
telemetry: "TelemetryClient",
|
|
251
|
+
ctx: "RunContext",
|
|
252
|
+
*,
|
|
253
|
+
end_event: str,
|
|
254
|
+
error_event: str,
|
|
255
|
+
fields: dict,
|
|
256
|
+
end_details: Optional[dict],
|
|
257
|
+
exc: Optional[BaseException] = None,
|
|
258
|
+
interrupted: bool = False,
|
|
259
|
+
) -> None:
|
|
260
|
+
"""Emit a run's terminal telemetry and close out its span attributes.
|
|
261
|
+
|
|
262
|
+
The single place a run ends, whichever form started it: the ``with`` block
|
|
263
|
+
below, or the deferred :class:`AgentRun`. Usage and model land on the span
|
|
264
|
+
first (attributes set after ``end()`` are silently dropped), then the
|
|
265
|
+
terminal log goes out, and only then is the error recorded on the span --
|
|
266
|
+
the order the hooks' payload tests pin.
|
|
267
|
+
|
|
268
|
+
``exc`` marks a failed run, ``interrupted`` a streaming consumer that
|
|
269
|
+
stopped early or a cancelled run; a caller-supplied ``end_details`` still
|
|
270
|
+
wins over the interrupted status, because a hook that named its own terminal
|
|
271
|
+
details (CrewAI's ``completed``) means them either way.
|
|
272
|
+
"""
|
|
273
|
+
_close_span(ctx.span, ctx) # partial usage/content on failed runs
|
|
274
|
+
otel.set_model(ctx.span, ctx.model)
|
|
275
|
+
if exc is not None:
|
|
276
|
+
telemetry.send_log(
|
|
277
|
+
event_type=error_event,
|
|
278
|
+
error=str(exc),
|
|
279
|
+
traceback="".join(tb.format_exception(exc)),
|
|
280
|
+
details=_merge_run_details(None, ctx),
|
|
281
|
+
**_with_model(fields, ctx),
|
|
282
|
+
)
|
|
283
|
+
otel.record_error(ctx.span, exc)
|
|
284
|
+
return
|
|
285
|
+
default = {"status": "interrupted"} if interrupted else {"status": "success"}
|
|
286
|
+
telemetry.send_log(
|
|
287
|
+
event_type=end_event,
|
|
288
|
+
details=_merge_run_details(
|
|
289
|
+
end_details if end_details is not None else default, ctx
|
|
290
|
+
),
|
|
291
|
+
**_with_model(fields, ctx),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@contextmanager
|
|
296
|
+
def trace_agent_run(
|
|
297
|
+
telemetry: "TelemetryClient",
|
|
298
|
+
span_name: str,
|
|
299
|
+
span_attributes: dict,
|
|
300
|
+
*,
|
|
301
|
+
start_event: str,
|
|
302
|
+
end_event: str,
|
|
303
|
+
error_event: str,
|
|
304
|
+
log_fields: Optional[dict] = None,
|
|
305
|
+
start_details: Optional[dict] = None,
|
|
306
|
+
end_details: Optional[dict] = None,
|
|
307
|
+
session_name_hint: Optional[str] = None,
|
|
308
|
+
):
|
|
309
|
+
"""Open an otelzero span and emit start/success/error telemetry around a run.
|
|
310
|
+
|
|
311
|
+
Wraps the boilerplate shared by every framework hook: open an OTel span,
|
|
312
|
+
send a ``*_start`` log, then -- around the wrapped call running in the
|
|
313
|
+
``with`` body -- send ``*_end`` on success or ``*_error`` and re-raise on
|
|
314
|
+
failure (recording the error on the span). A successful run leaves the span
|
|
315
|
+
status untouched, which the conventions require.
|
|
316
|
+
|
|
317
|
+
Yields a :class:`RunContext`; token usage attached via ``set_usage`` is
|
|
318
|
+
merged into the terminal log's ``details["usage"]`` and set as
|
|
319
|
+
``gen_ai.usage.*`` span attributes, and a model named via ``set_model``
|
|
320
|
+
fills in ``model`` on the terminal event and the span when the hook could
|
|
321
|
+
not read one before the run. Content attached via ``set_input`` /
|
|
322
|
+
``set_output`` / ``set_system`` lands the same way -- on the terminal log
|
|
323
|
+
(success, error or interrupted alike) and as the Opt-In ``gen_ai.*``
|
|
324
|
+
content attributes -- but only when the user opted in to content capture.
|
|
325
|
+
|
|
326
|
+
``log_fields`` (e.g. ``agent_name``/``agent_role``/``model``) are attached
|
|
327
|
+
to every log this emits; ``start_details``/``end_details`` populate the start/end logs'
|
|
328
|
+
``details`` (``end_details`` defaults to ``{"status": "success"}``).
|
|
329
|
+
``session_name_hint`` -- the run's intent text (a task description, agent
|
|
330
|
+
goal, or prompt) -- names the session before the start event is sent, so the
|
|
331
|
+
dashboard and apps show a meaningful title; the first non-empty hint across a
|
|
332
|
+
session's runs wins (see :meth:`TelemetryClient.suggest_session_name`). Works for
|
|
333
|
+
sync, ``async``, and async-generator wrapped calls alike, since the wrapped
|
|
334
|
+
call lives in the caller's ``with`` body. When a streaming consumer stops
|
|
335
|
+
early (``GeneratorExit`` in an async-generator wrapper), the ``*_end``
|
|
336
|
+
event is still emitted -- with ``{"status": "interrupted"}`` unless the hook
|
|
337
|
+
provided ``end_details`` -- so interrupted runs don't dangle open.
|
|
338
|
+
"""
|
|
339
|
+
fields = log_fields or {}
|
|
340
|
+
telemetry.suggest_session_name(session_name_hint)
|
|
341
|
+
with otel.span(span_name, _with_identity(telemetry, span_attributes)) as span:
|
|
342
|
+
ctx = RunContext(span)
|
|
343
|
+
telemetry.send_log(event_type=start_event, details=start_details, **fields)
|
|
344
|
+
|
|
345
|
+
def close(**outcome):
|
|
346
|
+
_close_run(
|
|
347
|
+
telemetry,
|
|
348
|
+
ctx,
|
|
349
|
+
end_event=end_event,
|
|
350
|
+
error_event=error_event,
|
|
351
|
+
fields=fields,
|
|
352
|
+
end_details=end_details,
|
|
353
|
+
**outcome,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
yield ctx
|
|
358
|
+
except GeneratorExit:
|
|
359
|
+
# Streaming consumer stopped early (break/aclose): close the run
|
|
360
|
+
# with whatever usage accumulated, then keep unwinding.
|
|
361
|
+
close(interrupted=True)
|
|
362
|
+
raise
|
|
363
|
+
except Exception as e:
|
|
364
|
+
close(exc=e)
|
|
365
|
+
raise
|
|
366
|
+
close()
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
class AgentRun:
|
|
370
|
+
"""A run that is started now and finished later, from wherever it ends.
|
|
371
|
+
|
|
372
|
+
:func:`trace_agent_run`'s ``with`` block assumes the wrapped call *is* the
|
|
373
|
+
run. Some frameworks break that assumption: LlamaIndex's ``Workflow.run`` is
|
|
374
|
+
synchronous, schedules an asyncio task and returns a handler immediately, so
|
|
375
|
+
a block around it would close the span before the run started. Such a hook
|
|
376
|
+
opens the run with :func:`start_agent_run`, holds :meth:`activate` around the
|
|
377
|
+
call that schedules the task -- so the task inherits the span as its parent --
|
|
378
|
+
and calls :meth:`finish` from the task's done-callback.
|
|
379
|
+
|
|
380
|
+
:meth:`finish` is idempotent: a done-callback and a teardown path may both
|
|
381
|
+
reach for a run that is already closed, and reporting it twice would show as
|
|
382
|
+
two overlapping runs of one agent.
|
|
383
|
+
"""
|
|
384
|
+
|
|
385
|
+
__slots__ = ("ctx", "_telemetry", "_events", "_fields", "_end_details", "_closed")
|
|
386
|
+
|
|
387
|
+
def __init__(self, telemetry, span, *, events, fields, end_details):
|
|
388
|
+
self.ctx = RunContext(span)
|
|
389
|
+
self._telemetry = telemetry
|
|
390
|
+
self._events = events # (end_event, error_event)
|
|
391
|
+
self._fields = fields
|
|
392
|
+
self._end_details = end_details
|
|
393
|
+
self._closed = False
|
|
394
|
+
|
|
395
|
+
@property
|
|
396
|
+
def span(self):
|
|
397
|
+
return self.ctx.span
|
|
398
|
+
|
|
399
|
+
def set_usage(self, usage: Optional[dict]) -> None:
|
|
400
|
+
self.ctx.set_usage(usage)
|
|
401
|
+
|
|
402
|
+
def set_model(self, model: Optional[str]) -> None:
|
|
403
|
+
self.ctx.set_model(model)
|
|
404
|
+
|
|
405
|
+
def set_input(self, messages) -> None:
|
|
406
|
+
self.ctx.set_input(messages)
|
|
407
|
+
|
|
408
|
+
def set_output(self, messages, finish_reasons=None) -> None:
|
|
409
|
+
self.ctx.set_output(messages, finish_reasons)
|
|
410
|
+
|
|
411
|
+
def set_system(self, parts) -> None:
|
|
412
|
+
self.ctx.set_system(parts)
|
|
413
|
+
|
|
414
|
+
@contextmanager
|
|
415
|
+
def activate(self):
|
|
416
|
+
"""Make the run's span current for the block, without ending it."""
|
|
417
|
+
with otel.use_span(self.ctx.span):
|
|
418
|
+
yield self
|
|
419
|
+
|
|
420
|
+
def finish(
|
|
421
|
+
self, exc: Optional[BaseException] = None, interrupted: bool = False
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Close the run exactly once: terminal event, then end the span."""
|
|
424
|
+
if self._closed:
|
|
425
|
+
return
|
|
426
|
+
self._closed = True
|
|
427
|
+
end_event, error_event = self._events
|
|
428
|
+
try:
|
|
429
|
+
_close_run(
|
|
430
|
+
self._telemetry,
|
|
431
|
+
self.ctx,
|
|
432
|
+
end_event=end_event,
|
|
433
|
+
error_event=error_event,
|
|
434
|
+
fields=self._fields,
|
|
435
|
+
end_details=self._end_details,
|
|
436
|
+
exc=exc,
|
|
437
|
+
interrupted=interrupted,
|
|
438
|
+
)
|
|
439
|
+
finally:
|
|
440
|
+
otel.end_span(self.ctx.span)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def start_agent_run(
|
|
444
|
+
telemetry: "TelemetryClient",
|
|
445
|
+
span_name: str,
|
|
446
|
+
span_attributes: dict,
|
|
447
|
+
*,
|
|
448
|
+
start_event: str,
|
|
449
|
+
end_event: str,
|
|
450
|
+
error_event: str,
|
|
451
|
+
log_fields: Optional[dict] = None,
|
|
452
|
+
start_details: Optional[dict] = None,
|
|
453
|
+
end_details: Optional[dict] = None,
|
|
454
|
+
session_name_hint: Optional[str] = None,
|
|
455
|
+
) -> AgentRun:
|
|
456
|
+
"""Open a run whose end is reported later -- the deferred :func:`trace_agent_run`.
|
|
457
|
+
|
|
458
|
+
Takes the same arguments and emits the same payloads; the difference is that
|
|
459
|
+
the span is started *without* being made current and the terminal event is
|
|
460
|
+
the caller's job, via the returned :class:`AgentRun`. For a framework whose
|
|
461
|
+
run entry point returns a handle rather than a result -- see
|
|
462
|
+
:mod:`otelzero.hooks.llama_index`.
|
|
463
|
+
"""
|
|
464
|
+
fields = log_fields or {}
|
|
465
|
+
telemetry.suggest_session_name(session_name_hint)
|
|
466
|
+
span = otel.start_span(span_name, _with_identity(telemetry, span_attributes))
|
|
467
|
+
run = AgentRun(
|
|
468
|
+
telemetry,
|
|
469
|
+
span,
|
|
470
|
+
events=(end_event, error_event),
|
|
471
|
+
fields=fields,
|
|
472
|
+
end_details=end_details,
|
|
473
|
+
)
|
|
474
|
+
telemetry.send_log(event_type=start_event, details=start_details, **fields)
|
|
475
|
+
return run
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def nesting_guard(name: str):
|
|
479
|
+
"""Build a re-entrancy guard for a framework whose run entry points nest.
|
|
480
|
+
|
|
481
|
+
Some frameworks offer several public ways to start a run that are really one
|
|
482
|
+
run underneath: LangGraph's ``invoke`` drains its own ``stream``, and a graph
|
|
483
|
+
node holding a subgraph re-enters the runtime a level down; the OpenAI Agents
|
|
484
|
+
SDK's ``run_sync`` drives ``run``. Wrapping each entry point is still right --
|
|
485
|
+
callers use all of them -- but only the outermost call should open a span, or
|
|
486
|
+
one agent run would be reported as two overlapping ones.
|
|
487
|
+
|
|
488
|
+
Returns a context-manager factory yielding ``True`` for the outermost call on
|
|
489
|
+
the current context and ``False`` for anything nested inside it. The depth
|
|
490
|
+
lives in a :class:`~contextvars.ContextVar`, so concurrent ``asyncio`` tasks
|
|
491
|
+
and threads each track their own nesting rather than masking each other's
|
|
492
|
+
runs. Frameworks that hand node execution to a worker thread *without*
|
|
493
|
+
copying the context will not nest across that boundary; the guard degrades to
|
|
494
|
+
reporting the inner run separately, never to losing one.
|
|
495
|
+
"""
|
|
496
|
+
var: contextvars.ContextVar = contextvars.ContextVar(
|
|
497
|
+
f"otelzero_depth_{name}", default=0
|
|
498
|
+
)
|
|
499
|
+
|
|
500
|
+
@contextmanager
|
|
501
|
+
def guard():
|
|
502
|
+
if var.get():
|
|
503
|
+
yield False
|
|
504
|
+
return
|
|
505
|
+
token = var.set(1)
|
|
506
|
+
try:
|
|
507
|
+
yield True
|
|
508
|
+
finally:
|
|
509
|
+
var.reset(token)
|
|
510
|
+
|
|
511
|
+
return guard
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def install_hook(
|
|
515
|
+
hook_cls,
|
|
516
|
+
*,
|
|
517
|
+
framework: str,
|
|
518
|
+
label: str,
|
|
519
|
+
server_url: str,
|
|
520
|
+
user: Optional[str],
|
|
521
|
+
api_key: Optional[str] = None,
|
|
522
|
+
session: Optional[str] = None,
|
|
523
|
+
name: Optional[str] = None,
|
|
524
|
+
) -> TelemetryClient:
|
|
525
|
+
"""Wire up a framework hook: build the client and register it.
|
|
526
|
+
|
|
527
|
+
Creates a :class:`TelemetryClient` for ``framework``, instantiates
|
|
528
|
+
``hook_cls`` and inserts it at the front of ``sys.meta_path`` so subsequent
|
|
529
|
+
imports of the framework are instrumented. ``label`` is the human-readable
|
|
530
|
+
name used in the ``[MONITOR]`` console output. ``api_key`` ties events to an
|
|
531
|
+
owner (for the iOS app); ``session`` groups one run's events; ``name`` is an
|
|
532
|
+
explicit session title (else it is derived from the run's intent). Returns
|
|
533
|
+
the telemetry client.
|
|
534
|
+
"""
|
|
535
|
+
telemetry = TelemetryClient(
|
|
536
|
+
server_url,
|
|
537
|
+
framework=framework,
|
|
538
|
+
user=user,
|
|
539
|
+
api_key=api_key,
|
|
540
|
+
session=session,
|
|
541
|
+
name=name,
|
|
542
|
+
)
|
|
543
|
+
hook = hook_cls(telemetry)
|
|
544
|
+
sys.meta_path.insert(0, hook)
|
|
545
|
+
print(f"[MONITOR] {label} monitoring installed (server: {server_url})")
|
|
546
|
+
return telemetry
|