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,517 @@
|
|
|
1
|
+
"""Shared plumbing for frameworks whose *own* OpenTelemetry otelzero switches on.
|
|
2
|
+
|
|
3
|
+
Some frameworks already trace themselves in the GenAI semantic conventions:
|
|
4
|
+
an ``invoke_agent {name}`` span per run, with ``chat`` / ``execute_tool`` (and
|
|
5
|
+
framework-specific) spans nested under it. For those, wrapping a method from
|
|
6
|
+
the outside sees less than the framework records, so their hooks wrap nothing.
|
|
7
|
+
Each one configures the framework's native instrumentation and attaches one
|
|
8
|
+
:class:`NativeRunProcessor` to the tracer provider the spans go through.
|
|
9
|
+
The processor does two things:
|
|
10
|
+
|
|
11
|
+
* **Identity.** It stamps otelzero's run identity (``gen_ai.conversation.id``,
|
|
12
|
+
``user.name`` and ``otelzero.session.name``) on every span the framework
|
|
13
|
+
starts, and on any other instrumentation's span nested inside one of them
|
|
14
|
+
(an HTTP client's span under a model call, say). That way a trace can be
|
|
15
|
+
filtered by session the same way the wrapping hooks' spans can.
|
|
16
|
+
* **Events.** It turns each native ``invoke_agent`` span into the
|
|
17
|
+
``agent_start`` / ``agent_end`` / ``agent_error`` HTTP events the wrapping
|
|
18
|
+
hooks send, so the dashboard's event feed doesn't change.
|
|
19
|
+
|
|
20
|
+
Frameworks fill in a span at different points. Pydantic AI passes its
|
|
21
|
+
attributes when it starts a span. Strands, Agent Framework and ADK start an
|
|
22
|
+
empty span and set the attributes afterwards, so ``on_start`` sees only the
|
|
23
|
+
span's name. Because of that, a run is recognised by its ``invoke_agent``
|
|
24
|
+
operation *or* its ``invoke_agent {name}`` span name. ``agent_start`` carries
|
|
25
|
+
what is known at that point. ``agent_end`` carries what the run recorded by the
|
|
26
|
+
time it finished: the model, the tools offered, and token usage, taken from the
|
|
27
|
+
run span itself or, where the framework records them only on its model-call
|
|
28
|
+
spans, from those spans.
|
|
29
|
+
|
|
30
|
+
Each framework gets one processor per provider (a provider cannot drop a
|
|
31
|
+
processor). A reinstall retargets the processor at the new client, and an
|
|
32
|
+
uninstall makes it dormant. Processors are scoped by instrumentation-scope
|
|
33
|
+
name, so two native frameworks in one process don't report each other's runs.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import json
|
|
37
|
+
import re
|
|
38
|
+
import threading
|
|
39
|
+
from typing import Any, Dict, Iterable, Optional, Tuple
|
|
40
|
+
|
|
41
|
+
from otelzero import otel, semconv
|
|
42
|
+
from otelzero.telemetry import TelemetryClient
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
from opentelemetry.sdk.trace import SpanProcessor as _SpanProcessor
|
|
46
|
+
except ImportError: # pragma: no cover - 'otel' extra absent; install raises
|
|
47
|
+
_SpanProcessor = object # type: ignore[assignment,misc]
|
|
48
|
+
|
|
49
|
+
# Model-call operations whose spans may carry the model, tools and usage when
|
|
50
|
+
# the agent span itself does not (ADK records them only there).
|
|
51
|
+
_MODEL_CALL_OPERATIONS = frozenset(
|
|
52
|
+
{semconv.OP_CHAT, semconv.OP_TEXT_COMPLETION, semconv.OP_GENERATE_CONTENT}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
# Where frameworks put the model on a span: the convention's attribute, then
|
|
56
|
+
# Pydantic AI's own ``model_name`` (set on its agent span at start).
|
|
57
|
+
_MODEL_KEYS = (semconv.GEN_AI_REQUEST_MODEL, semconv.GEN_AI_RESPONSE_MODEL, "model_name")
|
|
58
|
+
|
|
59
|
+
# Token usage: Pydantic AI's per-run totals live under its own
|
|
60
|
+
# ``gen_ai.aggregated_usage.*`` namespace; everyone else uses ``gen_ai.usage.*``.
|
|
61
|
+
_USAGE_PREFIXES = ("gen_ai.aggregated_usage.", "gen_ai.usage.")
|
|
62
|
+
_USAGE_KEYS = {
|
|
63
|
+
"input_tokens": "input_tokens",
|
|
64
|
+
"output_tokens": "output_tokens",
|
|
65
|
+
"reasoning_tokens": "reasoning.output_tokens",
|
|
66
|
+
"cache_read_tokens": "cache_read.input_tokens",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
# Strands lists the agent's tool *names* here (a JSON array) on every agent
|
|
70
|
+
# span, whereas ``gen_ai.tool.definitions`` is opt-in there.
|
|
71
|
+
_AGENT_TOOLS = "gen_ai.agent.tools"
|
|
72
|
+
|
|
73
|
+
_SPAN_NAME = re.compile(r"^invoke_agent(?:\s+(?P<name>.+))?$")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def marker(framework: str) -> str:
|
|
77
|
+
"""The provider attribute holding *framework*'s processor."""
|
|
78
|
+
return "_otelzero_native_" + re.sub(r"\W+", "_", framework.lower()).strip("_")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def usage_from(attributes: Any) -> Optional[dict]:
|
|
82
|
+
"""otelzero's normalized usage dict from a span's attributes, or ``None``.
|
|
83
|
+
|
|
84
|
+
Same per-field shape :mod:`otelzero.hooks._usage` produces for the
|
|
85
|
+
wrapping hooks.
|
|
86
|
+
"""
|
|
87
|
+
for prefix in _USAGE_PREFIXES:
|
|
88
|
+
usage = {key: attributes.get(prefix + attr) for key, attr in _USAGE_KEYS.items()}
|
|
89
|
+
if usage["input_tokens"] is None and usage["output_tokens"] is None:
|
|
90
|
+
continue
|
|
91
|
+
usage["input_tokens"] = usage["input_tokens"] or 0
|
|
92
|
+
usage["output_tokens"] = usage["output_tokens"] or 0
|
|
93
|
+
usage["total_tokens"] = usage["input_tokens"] + usage["output_tokens"]
|
|
94
|
+
return usage
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _add_usage(total: Optional[dict], more: Optional[dict]) -> Optional[dict]:
|
|
99
|
+
if more is None:
|
|
100
|
+
return total
|
|
101
|
+
if total is None:
|
|
102
|
+
return dict(more)
|
|
103
|
+
for key, value in more.items():
|
|
104
|
+
if value is not None:
|
|
105
|
+
total[key] = (total.get(key) or 0) + value
|
|
106
|
+
return total
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def tool_names(attributes: Any) -> list:
|
|
110
|
+
"""Tool names a span says the agent was offered.
|
|
111
|
+
|
|
112
|
+
Read from ``gen_ai.tool.definitions`` (a JSON array of definitions) and
|
|
113
|
+
from Strands' ``gen_ai.agent.tools`` (a JSON array of names).
|
|
114
|
+
"""
|
|
115
|
+
names = []
|
|
116
|
+
for key in (semconv.GEN_AI_TOOL_DEFINITIONS, _AGENT_TOOLS):
|
|
117
|
+
raw = attributes.get(key)
|
|
118
|
+
if not raw:
|
|
119
|
+
continue
|
|
120
|
+
try:
|
|
121
|
+
entries = json.loads(raw) if isinstance(raw, str) else list(raw)
|
|
122
|
+
except (TypeError, ValueError):
|
|
123
|
+
continue
|
|
124
|
+
if not isinstance(entries, list):
|
|
125
|
+
continue
|
|
126
|
+
for entry in entries:
|
|
127
|
+
if isinstance(entry, str) and entry:
|
|
128
|
+
names.append(entry)
|
|
129
|
+
elif isinstance(entry, dict) and entry.get("name"):
|
|
130
|
+
names.append(entry["name"])
|
|
131
|
+
return names
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def model_from(attributes: Any) -> Optional[str]:
|
|
135
|
+
for key in _MODEL_KEYS:
|
|
136
|
+
value = attributes.get(key)
|
|
137
|
+
if isinstance(value, str) and value:
|
|
138
|
+
return value
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def exception_of(span: Any) -> Tuple[str, Optional[str]]:
|
|
143
|
+
"""``(message, traceback)`` from the span's last ``exception`` event.
|
|
144
|
+
|
|
145
|
+
A framework that records only the exception type (Pydantic AI or Agent
|
|
146
|
+
Framework without content capture) gets the type as the message.
|
|
147
|
+
"""
|
|
148
|
+
for event in reversed(span.events or ()):
|
|
149
|
+
if event.name == "exception":
|
|
150
|
+
attrs = event.attributes or {}
|
|
151
|
+
message = attrs.get("exception.message") or attrs.get("exception.type")
|
|
152
|
+
return message, attrs.get("exception.stacktrace")
|
|
153
|
+
# Agent Framework records neither the event nor a description without
|
|
154
|
+
# sensitive data enabled, only the convention's ``error.type``.
|
|
155
|
+
description = getattr(span.status, "description", None)
|
|
156
|
+
error_type = (span.attributes or {}).get(semconv.ERROR_TYPE)
|
|
157
|
+
return description or error_type or "Agent run failed", None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_agent_name(span: Any, attributes: Any) -> Optional[str]:
|
|
161
|
+
"""The agent's name when *span* is an agent run, else ``None``.
|
|
162
|
+
|
|
163
|
+
A span counts as a run if its ``gen_ai.operation.name`` says
|
|
164
|
+
``invoke_agent``, or (for frameworks that set attributes only after the
|
|
165
|
+
span starts) if its name is ``invoke_agent`` / ``invoke_agent {agent}``.
|
|
166
|
+
The name then comes from ``gen_ai.agent.name`` or, failing that, from the
|
|
167
|
+
span name. An empty string means it's a run but has no name.
|
|
168
|
+
"""
|
|
169
|
+
operation = attributes.get(semconv.GEN_AI_OPERATION_NAME)
|
|
170
|
+
match = _SPAN_NAME.match(span.name or "")
|
|
171
|
+
if operation is not None and operation != semconv.OP_INVOKE_AGENT:
|
|
172
|
+
return None
|
|
173
|
+
if operation is None and match is None:
|
|
174
|
+
return None
|
|
175
|
+
return attributes.get(semconv.GEN_AI_AGENT_NAME) or (
|
|
176
|
+
match.group("name") if match and match.group("name") else ""
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class NativeRunProcessor(_SpanProcessor):
|
|
181
|
+
"""Run identity on native spans; lifecycle events from ``invoke_agent``.
|
|
182
|
+
|
|
183
|
+
Args:
|
|
184
|
+
telemetry: The client events go out on (``None`` = dormant).
|
|
185
|
+
framework: Label for log messages.
|
|
186
|
+
scopes: Instrumentation-scope name prefixes this framework's spans
|
|
187
|
+
use. Spans from other scopes are only touched when they are
|
|
188
|
+
nested inside one of this framework's spans.
|
|
189
|
+
default_agent_name: Name for a run whose span names no agent.
|
|
190
|
+
send_events: Derive ``agent_*`` events from ``invoke_agent`` spans.
|
|
191
|
+
Off for LangSmith, whose spans arrive in batches after the fact;
|
|
192
|
+
its hook sends the events from LangChain's callbacks instead.
|
|
193
|
+
native_conversation_id: Keep the framework's own
|
|
194
|
+
``gen_ai.conversation.id`` instead of replacing it with otelzero's
|
|
195
|
+
session, and file the run's events under it too. Use this for a
|
|
196
|
+
framework whose conversation id is a real, caller-chosen session
|
|
197
|
+
(ADK) and which writes that id after a span starts, past the point
|
|
198
|
+
where the processor could replace it.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
def __init__(
|
|
202
|
+
self,
|
|
203
|
+
telemetry: Optional[TelemetryClient],
|
|
204
|
+
*,
|
|
205
|
+
framework: str,
|
|
206
|
+
scopes: Iterable[str],
|
|
207
|
+
default_agent_name: str,
|
|
208
|
+
native_conversation_id: bool = False,
|
|
209
|
+
send_events: bool = True,
|
|
210
|
+
):
|
|
211
|
+
self.telemetry = telemetry
|
|
212
|
+
self.send_events = send_events
|
|
213
|
+
self.framework = framework
|
|
214
|
+
self.scopes = tuple(scopes)
|
|
215
|
+
self.default_agent_name = default_agent_name
|
|
216
|
+
self.native_conversation_id = native_conversation_id
|
|
217
|
+
# span id -> id of the run span it belongs to (None: one of ours, but
|
|
218
|
+
# not inside a run -- an ADK workflow span, say).
|
|
219
|
+
self._owner: Dict[int, Optional[int]] = {}
|
|
220
|
+
# run span id -> what the run's spans revealed so far.
|
|
221
|
+
self._runs: Dict[int, dict] = {}
|
|
222
|
+
# trace id -> the framework's own conversation id, when adopted, and
|
|
223
|
+
# how many of the trace's tracked spans are still open (the entry goes
|
|
224
|
+
# when the last one ends, whatever span the trace's root is).
|
|
225
|
+
self._conversations: Dict[int, str] = {}
|
|
226
|
+
self._open_in_trace: Dict[int, int] = {}
|
|
227
|
+
self._lock = threading.Lock()
|
|
228
|
+
|
|
229
|
+
# -- helpers ------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
def _is_ours(self, span: Any) -> bool:
|
|
232
|
+
scope = getattr(span, "instrumentation_scope", None)
|
|
233
|
+
name = getattr(scope, "name", None) or ""
|
|
234
|
+
return any(name.startswith(prefix) for prefix in self.scopes)
|
|
235
|
+
|
|
236
|
+
def _send(self, telemetry: TelemetryClient, run: dict, **fields: Any) -> None:
|
|
237
|
+
if run.get("conversation_id"):
|
|
238
|
+
fields["session"] = run["conversation_id"]
|
|
239
|
+
telemetry.send_log(**fields)
|
|
240
|
+
|
|
241
|
+
# -- SpanProcessor ------------------------------------------------------
|
|
242
|
+
|
|
243
|
+
def on_start(self, span: Any, parent_context: Any = None) -> None:
|
|
244
|
+
telemetry = self.telemetry
|
|
245
|
+
if telemetry is None:
|
|
246
|
+
return
|
|
247
|
+
span_id = span.context.span_id
|
|
248
|
+
parent_id = span.parent.span_id if span.parent else None
|
|
249
|
+
with self._lock:
|
|
250
|
+
inherited = parent_id in self._owner
|
|
251
|
+
parent_run = self._owner.get(parent_id) if inherited else None
|
|
252
|
+
ours = self._is_ours(span)
|
|
253
|
+
if not (inherited or ours):
|
|
254
|
+
return
|
|
255
|
+
|
|
256
|
+
attributes = span.attributes or {}
|
|
257
|
+
trace_id = span.context.trace_id
|
|
258
|
+
native_conversation = None
|
|
259
|
+
if self.native_conversation_id:
|
|
260
|
+
native_conversation = attributes.get(semconv.GEN_AI_CONVERSATION_ID)
|
|
261
|
+
with self._lock:
|
|
262
|
+
if native_conversation:
|
|
263
|
+
self._conversations.setdefault(trace_id, native_conversation)
|
|
264
|
+
native_conversation = self._conversations.get(trace_id)
|
|
265
|
+
|
|
266
|
+
user, session, session_name = telemetry.run_identity()
|
|
267
|
+
if self.native_conversation_id:
|
|
268
|
+
# Spans the framework leaves without one (ADK's execute_tool) get
|
|
269
|
+
# the trace's id, so every span still filters by session.
|
|
270
|
+
if native_conversation and not attributes.get(semconv.GEN_AI_CONVERSATION_ID):
|
|
271
|
+
span.set_attribute(semconv.GEN_AI_CONVERSATION_ID, native_conversation)
|
|
272
|
+
elif session:
|
|
273
|
+
span.set_attribute(semconv.GEN_AI_CONVERSATION_ID, session)
|
|
274
|
+
if session_name:
|
|
275
|
+
span.set_attribute(semconv.OTELZERO_SESSION_NAME, session_name)
|
|
276
|
+
if user:
|
|
277
|
+
span.set_attribute(semconv.USER_NAME, user)
|
|
278
|
+
|
|
279
|
+
with self._lock:
|
|
280
|
+
self._open_in_trace[trace_id] = self._open_in_trace.get(trace_id, 0) + 1
|
|
281
|
+
agent_name = _run_agent_name(span, attributes) if ours and self.send_events else None
|
|
282
|
+
if agent_name is None:
|
|
283
|
+
with self._lock:
|
|
284
|
+
self._owner[span_id] = parent_run
|
|
285
|
+
return
|
|
286
|
+
|
|
287
|
+
agent_name = agent_name or self.default_agent_name
|
|
288
|
+
model = model_from(attributes)
|
|
289
|
+
run = {
|
|
290
|
+
"agent_name": agent_name,
|
|
291
|
+
"model": model,
|
|
292
|
+
"tools": set(tool_names(attributes)),
|
|
293
|
+
"child_model": None,
|
|
294
|
+
"child_usage": None,
|
|
295
|
+
"conversation_id": native_conversation,
|
|
296
|
+
}
|
|
297
|
+
with self._lock:
|
|
298
|
+
self._owner[span_id] = span_id
|
|
299
|
+
self._runs[span_id] = run
|
|
300
|
+
self._send(
|
|
301
|
+
telemetry,
|
|
302
|
+
run,
|
|
303
|
+
event_type="agent_start",
|
|
304
|
+
agent_name=agent_name,
|
|
305
|
+
agent_role=agent_name,
|
|
306
|
+
model=model,
|
|
307
|
+
details={"model": model},
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
def on_end(self, span: Any) -> None:
|
|
311
|
+
span_id = span.context.span_id
|
|
312
|
+
with self._lock:
|
|
313
|
+
if span_id not in self._owner:
|
|
314
|
+
return
|
|
315
|
+
owner = self._owner.pop(span_id)
|
|
316
|
+
run = self._runs.pop(span_id, None) if owner == span_id else None
|
|
317
|
+
trace_id = span.context.trace_id
|
|
318
|
+
remaining = self._open_in_trace.get(trace_id, 1) - 1
|
|
319
|
+
if remaining > 0:
|
|
320
|
+
self._open_in_trace[trace_id] = remaining
|
|
321
|
+
else:
|
|
322
|
+
self._open_in_trace.pop(trace_id, None)
|
|
323
|
+
self._conversations.pop(trace_id, None)
|
|
324
|
+
attributes = span.attributes or {}
|
|
325
|
+
|
|
326
|
+
if run is None:
|
|
327
|
+
# A span inside a run: harvest what the agent span may not carry.
|
|
328
|
+
if owner is None:
|
|
329
|
+
return
|
|
330
|
+
if attributes.get(semconv.GEN_AI_OPERATION_NAME) not in _MODEL_CALL_OPERATIONS:
|
|
331
|
+
return
|
|
332
|
+
with self._lock:
|
|
333
|
+
parent_run = self._runs.get(owner)
|
|
334
|
+
if parent_run is None:
|
|
335
|
+
return
|
|
336
|
+
parent_run["tools"].update(tool_names(attributes))
|
|
337
|
+
parent_run["child_model"] = parent_run["child_model"] or model_from(attributes)
|
|
338
|
+
parent_run["child_usage"] = _add_usage(
|
|
339
|
+
parent_run["child_usage"], usage_from(attributes)
|
|
340
|
+
)
|
|
341
|
+
return
|
|
342
|
+
|
|
343
|
+
telemetry = self.telemetry
|
|
344
|
+
if telemetry is None:
|
|
345
|
+
return
|
|
346
|
+
run["tools"].update(tool_names(attributes))
|
|
347
|
+
model = model_from(attributes) or run["child_model"] or run["model"]
|
|
348
|
+
usage = usage_from(attributes) or run["child_usage"]
|
|
349
|
+
fields = {
|
|
350
|
+
"agent_name": run["agent_name"],
|
|
351
|
+
"agent_role": run["agent_name"],
|
|
352
|
+
"model": model,
|
|
353
|
+
}
|
|
354
|
+
if not span.status.is_ok:
|
|
355
|
+
error, traceback = exception_of(span)
|
|
356
|
+
self._send(
|
|
357
|
+
telemetry,
|
|
358
|
+
run,
|
|
359
|
+
event_type="agent_error",
|
|
360
|
+
error=error,
|
|
361
|
+
traceback=traceback,
|
|
362
|
+
details={"usage": usage} if usage else None,
|
|
363
|
+
**fields,
|
|
364
|
+
)
|
|
365
|
+
return
|
|
366
|
+
details: dict = {"status": "success"}
|
|
367
|
+
if run["tools"]:
|
|
368
|
+
details["tools"] = sorted(run["tools"])
|
|
369
|
+
if usage:
|
|
370
|
+
details["usage"] = usage
|
|
371
|
+
self._send(telemetry, run, event_type="agent_end", details=details, **fields)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def attach_processor(
|
|
375
|
+
provider: Any,
|
|
376
|
+
telemetry: TelemetryClient,
|
|
377
|
+
*,
|
|
378
|
+
framework: str,
|
|
379
|
+
scopes: Iterable[str],
|
|
380
|
+
default_agent_name: str,
|
|
381
|
+
native_conversation_id: bool = False,
|
|
382
|
+
send_events: bool = True,
|
|
383
|
+
key: Optional[str] = None,
|
|
384
|
+
) -> NativeRunProcessor:
|
|
385
|
+
"""Attach (or retarget) *framework*'s processor on *provider*.
|
|
386
|
+
|
|
387
|
+
*key* names the processor slot when several install functions share one
|
|
388
|
+
processor (LangChain and LangGraph both report LangSmith's spans).
|
|
389
|
+
"""
|
|
390
|
+
key = marker(key or framework)
|
|
391
|
+
existing = getattr(provider, key, None)
|
|
392
|
+
if existing is not None:
|
|
393
|
+
existing.telemetry = telemetry
|
|
394
|
+
existing.framework = framework
|
|
395
|
+
return existing
|
|
396
|
+
processor = NativeRunProcessor(
|
|
397
|
+
telemetry,
|
|
398
|
+
framework=framework,
|
|
399
|
+
scopes=scopes,
|
|
400
|
+
default_agent_name=default_agent_name,
|
|
401
|
+
native_conversation_id=native_conversation_id,
|
|
402
|
+
send_events=send_events,
|
|
403
|
+
)
|
|
404
|
+
provider.add_span_processor(processor)
|
|
405
|
+
setattr(provider, key, processor)
|
|
406
|
+
return processor
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def start_tracing(framework: str, telemetry: TelemetryClient, server_url: str) -> Any:
|
|
410
|
+
"""Force otelzero's OTLP provider on; raise if the SDK is missing.
|
|
411
|
+
|
|
412
|
+
Native instrumentation is the only channel these hooks report through, so
|
|
413
|
+
``OTELZERO_OTEL=0`` cannot switch them off.
|
|
414
|
+
"""
|
|
415
|
+
provider = otel.init_tracing(
|
|
416
|
+
server_url,
|
|
417
|
+
service_name=framework,
|
|
418
|
+
service_version=telemetry.framework_version,
|
|
419
|
+
api_key=telemetry.api_key,
|
|
420
|
+
force=True,
|
|
421
|
+
)
|
|
422
|
+
if provider is None:
|
|
423
|
+
raise RuntimeError(
|
|
424
|
+
f"{framework} monitoring reports only over OpenTelemetry; install the "
|
|
425
|
+
"'otel' extra: uv pip install 'otelzero[otel]'"
|
|
426
|
+
)
|
|
427
|
+
return provider
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def global_provider(provider: Any, framework: str) -> Any:
|
|
431
|
+
"""The provider a framework that traces through the *global* one will use.
|
|
432
|
+
|
|
433
|
+
Normally that's otelzero's own provider. If the application installed its
|
|
434
|
+
own SDK provider first, OpenTelemetry keeps that one global, the
|
|
435
|
+
framework's spans go there, and the processor has to be attached there to
|
|
436
|
+
see them. The spans then export to the application's exporters, not to the
|
|
437
|
+
registry. The events still reach it, which a warning points out.
|
|
438
|
+
"""
|
|
439
|
+
from opentelemetry import trace
|
|
440
|
+
|
|
441
|
+
current = trace.get_tracer_provider()
|
|
442
|
+
if current is provider or not hasattr(current, "add_span_processor"):
|
|
443
|
+
return provider
|
|
444
|
+
print(
|
|
445
|
+
f"[MONITOR] {framework}: a global OpenTelemetry tracer provider was "
|
|
446
|
+
"already configured, so the framework's spans export through it rather "
|
|
447
|
+
"than to the registry; otelzero's agent events are unaffected."
|
|
448
|
+
)
|
|
449
|
+
return current
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def detach(framework: str, provider: Any) -> None:
|
|
453
|
+
"""Make *framework*'s processor on *provider* dormant (no-op if absent)."""
|
|
454
|
+
processor = getattr(provider, marker(framework), None) if provider else None
|
|
455
|
+
if processor is not None:
|
|
456
|
+
processor.telemetry = None
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
_OPT_IN_ENV = "OTEL_SEMCONV_STABILITY_OPT_IN"
|
|
460
|
+
|
|
461
|
+
# token -> frameworks currently relying on it. Tokens the user had set before
|
|
462
|
+
# any hook added them are never in here, so they are never removed.
|
|
463
|
+
_opt_in_users: Dict[str, set] = {}
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _read_opt_in() -> list:
|
|
467
|
+
import os
|
|
468
|
+
|
|
469
|
+
return [t.strip() for t in os.environ.get(_OPT_IN_ENV, "").split(",") if t.strip()]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _write_opt_in(tokens: list) -> None:
|
|
473
|
+
import os
|
|
474
|
+
|
|
475
|
+
if tokens:
|
|
476
|
+
os.environ[_OPT_IN_ENV] = ",".join(tokens)
|
|
477
|
+
else:
|
|
478
|
+
os.environ.pop(_OPT_IN_ENV, None)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def opt_in_tokens(
|
|
482
|
+
framework: str, tokens: Iterable[str], *, skip_prefixes: Iterable[str] = ()
|
|
483
|
+
) -> None:
|
|
484
|
+
"""Add *tokens* to ``OTEL_SEMCONV_STABILITY_OPT_IN`` on *framework*'s behalf.
|
|
485
|
+
|
|
486
|
+
Tokens already present stay as they are. A token is also skipped when the
|
|
487
|
+
variable already holds an entry starting with one of *skip_prefixes*, so a
|
|
488
|
+
choice the user made explicitly is kept. Each added token is
|
|
489
|
+
reference-counted per framework, so :func:`release_opt_in` removes only what
|
|
490
|
+
no installed hook still needs, and never a token the user set.
|
|
491
|
+
"""
|
|
492
|
+
current = _read_opt_in()
|
|
493
|
+
skip = tuple(skip_prefixes)
|
|
494
|
+
for token in tokens:
|
|
495
|
+
users = _opt_in_users.get(token)
|
|
496
|
+
if users is not None:
|
|
497
|
+
users.add(framework)
|
|
498
|
+
continue
|
|
499
|
+
if token in current:
|
|
500
|
+
continue # the user's own token
|
|
501
|
+
if any(t.startswith(p) for t in current for p in skip if token.startswith(p)):
|
|
502
|
+
continue
|
|
503
|
+
current.append(token)
|
|
504
|
+
_opt_in_users[token] = {framework}
|
|
505
|
+
_write_opt_in(current)
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def release_opt_in(framework: str) -> None:
|
|
509
|
+
"""Drop *framework*'s claim on the tokens it added; remove the orphans."""
|
|
510
|
+
current = _read_opt_in()
|
|
511
|
+
for token, users in list(_opt_in_users.items()):
|
|
512
|
+
users.discard(framework)
|
|
513
|
+
if not users:
|
|
514
|
+
del _opt_in_users[token]
|
|
515
|
+
if token in current:
|
|
516
|
+
current.remove(token)
|
|
517
|
+
_write_opt_in(current)
|
otelzero/hooks/_usage.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"""Normalized LLM token-usage extraction shared by all framework hooks.
|
|
2
|
+
|
|
3
|
+
Every framework reports token usage under its own spelling (Anthropic's
|
|
4
|
+
``input_tokens``, OpenAI's ``prompt_tokens``, Gemini's ``prompt_token_count``,
|
|
5
|
+
Bedrock's ``inputTokens``, ...). :func:`extract_usage` folds any of them into
|
|
6
|
+
one normalized dict that the hooks attach to their terminal telemetry events::
|
|
7
|
+
|
|
8
|
+
{
|
|
9
|
+
"input_tokens": int, # 0 if only output known
|
|
10
|
+
"output_tokens": int,
|
|
11
|
+
"total_tokens": int, # reported total, else input + output
|
|
12
|
+
"reasoning_tokens": int | None, # None when the provider doesn't report
|
|
13
|
+
"cache_read_tokens": int | None,
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
All reads are dict-or-attr based and coercion-tolerant, so a framework version
|
|
17
|
+
drift degrades to "no usage" — never a crash in the wrapper.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from typing import Any, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _field(obj: Any, key: str) -> Any:
|
|
24
|
+
"""Read ``key`` off a dict or an object attribute; ``None`` when absent."""
|
|
25
|
+
if isinstance(obj, dict):
|
|
26
|
+
return obj.get(key)
|
|
27
|
+
return getattr(obj, key, None)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _int(value: Any) -> Optional[int]:
|
|
31
|
+
"""Coerce to int, tolerating floats/strings; ``None`` on bools or junk."""
|
|
32
|
+
if value is None or isinstance(value, bool):
|
|
33
|
+
return None
|
|
34
|
+
try:
|
|
35
|
+
return int(value)
|
|
36
|
+
except (TypeError, ValueError):
|
|
37
|
+
return None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _first(obj: Any, *keys: str) -> Optional[int]:
|
|
41
|
+
"""First key of ``keys`` that reads as an int off ``obj``."""
|
|
42
|
+
for key in keys:
|
|
43
|
+
val = _int(_field(obj, key))
|
|
44
|
+
if val is not None:
|
|
45
|
+
return val
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _nested(obj: Any, outer_keys, inner_keys) -> Optional[int]:
|
|
50
|
+
"""First ``outer.inner`` int found across the given key spellings."""
|
|
51
|
+
for outer in outer_keys:
|
|
52
|
+
detail = _field(obj, outer)
|
|
53
|
+
if detail is not None:
|
|
54
|
+
val = _first(detail, *inner_keys)
|
|
55
|
+
if val is not None:
|
|
56
|
+
return val
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# Alias tables covering every framework this repo instruments:
|
|
61
|
+
# Anthropic / Claude SDK: input_tokens / output_tokens / cache_read_input_tokens
|
|
62
|
+
# OpenAI chat: prompt_tokens / completion_tokens
|
|
63
|
+
# (+ completion_tokens_details.reasoning_tokens, prompt_tokens_details.cached_tokens)
|
|
64
|
+
# OpenAI Responses / MLflow ResponsesAgent: input_tokens / output_tokens
|
|
65
|
+
# (+ output_tokens_details.reasoning_tokens, input_tokens_details.cached_tokens)
|
|
66
|
+
# Google GenAI / ADK: prompt_token_count / candidates_token_count /
|
|
67
|
+
# thoughts_token_count / cached_content_token_count / total_token_count
|
|
68
|
+
# Microsoft agent_framework: input_token_count / output_token_count / total_token_count
|
|
69
|
+
# Bedrock / Strands: inputTokens / outputTokens / totalTokens / cacheReadInputTokens
|
|
70
|
+
# LangChain / LangGraph usage_metadata: input_tokens / output_tokens /
|
|
71
|
+
# total_tokens (+ output_token_details.reasoning, input_token_details.cache_read)
|
|
72
|
+
# CrewAI UsageMetrics: prompt_tokens / completion_tokens / cached_prompt_tokens
|
|
73
|
+
# OpenAI Agents SDK Usage: input_tokens / output_tokens / total_tokens
|
|
74
|
+
# Pydantic AI RunUsage: input_tokens / output_tokens / cache_read_tokens
|
|
75
|
+
# smolagents TokenUsage: input_tokens / output_tokens / total_tokens
|
|
76
|
+
_INPUT = (
|
|
77
|
+
"input_tokens",
|
|
78
|
+
"prompt_tokens",
|
|
79
|
+
"prompt_token_count",
|
|
80
|
+
"input_token_count",
|
|
81
|
+
"inputTokens",
|
|
82
|
+
)
|
|
83
|
+
_OUTPUT = (
|
|
84
|
+
"output_tokens",
|
|
85
|
+
"completion_tokens",
|
|
86
|
+
"candidates_token_count",
|
|
87
|
+
"output_token_count",
|
|
88
|
+
"outputTokens",
|
|
89
|
+
)
|
|
90
|
+
_TOTAL = ("total_tokens", "total_token_count", "totalTokens")
|
|
91
|
+
_REASONING_FLAT = ("thoughts_token_count", "reasoning_tokens")
|
|
92
|
+
_REASONING_NESTED = (
|
|
93
|
+
("output_token_details", "output_tokens_details", "completion_tokens_details"),
|
|
94
|
+
("reasoning", "reasoning_tokens"),
|
|
95
|
+
)
|
|
96
|
+
_CACHE_FLAT = (
|
|
97
|
+
"cache_read_input_tokens",
|
|
98
|
+
"cached_prompt_tokens",
|
|
99
|
+
"cached_content_token_count",
|
|
100
|
+
"cacheReadInputTokens",
|
|
101
|
+
# Pydantic AI's RunUsage spelling. It is also the key this module's own
|
|
102
|
+
# output uses, which makes extract_usage idempotent on a normalized dict.
|
|
103
|
+
"cache_read_tokens",
|
|
104
|
+
)
|
|
105
|
+
_CACHE_NESTED = (
|
|
106
|
+
("input_token_details", "input_tokens_details", "prompt_tokens_details"),
|
|
107
|
+
("cache_read", "cached_tokens"),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def extract_usage(raw: Any) -> Optional[dict]:
|
|
112
|
+
"""Normalize a framework usage object/dict; ``None`` when unrecognizable."""
|
|
113
|
+
if raw is None:
|
|
114
|
+
return None
|
|
115
|
+
input_t = _first(raw, *_INPUT)
|
|
116
|
+
output_t = _first(raw, *_OUTPUT)
|
|
117
|
+
if input_t is None and output_t is None:
|
|
118
|
+
return None
|
|
119
|
+
reasoning = _first(raw, *_REASONING_FLAT)
|
|
120
|
+
if reasoning is None:
|
|
121
|
+
reasoning = _nested(raw, *_REASONING_NESTED)
|
|
122
|
+
cache_read = _first(raw, *_CACHE_FLAT)
|
|
123
|
+
if cache_read is None:
|
|
124
|
+
cache_read = _nested(raw, *_CACHE_NESTED)
|
|
125
|
+
input_t, output_t = input_t or 0, output_t or 0
|
|
126
|
+
total = _first(raw, *_TOTAL)
|
|
127
|
+
return {
|
|
128
|
+
"input_tokens": input_t,
|
|
129
|
+
"output_tokens": output_t,
|
|
130
|
+
"total_tokens": total if total is not None else input_t + output_t,
|
|
131
|
+
"reasoning_tokens": reasoning,
|
|
132
|
+
"cache_read_tokens": cache_read,
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class UsageAccumulator:
|
|
137
|
+
"""Sum normalized usage across streamed messages/events.
|
|
138
|
+
|
|
139
|
+
Each streamed event's usage is treated as per-LLM-call and summed; if a
|
|
140
|
+
framework ever repeats one call's usage across partial events the sum
|
|
141
|
+
overcounts (accepted v1 tradeoff). ``result()`` is ``None`` until at least
|
|
142
|
+
one recognizable usage was added, so hooks never report fabricated zeros.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(self):
|
|
146
|
+
self._seen = False
|
|
147
|
+
self._totals = {
|
|
148
|
+
"input_tokens": 0,
|
|
149
|
+
"output_tokens": 0,
|
|
150
|
+
"total_tokens": 0,
|
|
151
|
+
"reasoning_tokens": None,
|
|
152
|
+
"cache_read_tokens": None,
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def add(self, usage: Optional[dict]) -> None:
|
|
156
|
+
if not usage:
|
|
157
|
+
return
|
|
158
|
+
self._seen = True
|
|
159
|
+
for key in ("input_tokens", "output_tokens", "total_tokens"):
|
|
160
|
+
self._totals[key] += usage.get(key) or 0
|
|
161
|
+
for key in ("reasoning_tokens", "cache_read_tokens"):
|
|
162
|
+
if usage.get(key) is not None:
|
|
163
|
+
self._totals[key] = (self._totals[key] or 0) + usage[key]
|
|
164
|
+
|
|
165
|
+
def result(self) -> Optional[dict]:
|
|
166
|
+
return dict(self._totals) if self._seen else None
|