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,504 @@
|
|
|
1
|
+
"""LangChain and LangGraph through LangSmith's *own* OpenTelemetry export.
|
|
2
|
+
|
|
3
|
+
LangChain and LangGraph trace themselves through LangSmith, and the
|
|
4
|
+
``langsmith`` SDK can export those traces as OpenTelemetry spans
|
|
5
|
+
(``langsmith[otel]``). Each run of a runnable, chain, graph node, model or tool
|
|
6
|
+
becomes one span, named after the run (``LangGraph``, ``RunnableSequence``, an
|
|
7
|
+
agent's name, ``model``, a tool's name). It carries ``langsmith.span.kind``,
|
|
8
|
+
``gen_ai.operation.name`` (``chain`` / ``chat`` / ``execute_tool`` / ...),
|
|
9
|
+
``gen_ai.request.model``, ``gen_ai.usage.*`` and ``langsmith.metadata.*``.
|
|
10
|
+
On otelzero's provider they pass through :mod:`otelzero.translate` on export,
|
|
11
|
+
which rewrites them into the current GenAI conventions: the root run becomes
|
|
12
|
+
``invoke_agent {run}``, a model call ``chat {model}``, a tool call
|
|
13
|
+
``execute_tool {tool}``, and run metadata (``thread_id`` / ``conversation_id``,
|
|
14
|
+
``user_name`` / ``user_id``) becomes ``gen_ai.conversation.id`` / ``user.name``.
|
|
15
|
+
If the application had already set its own global provider, they are exported
|
|
16
|
+
through that one, untranslated.
|
|
17
|
+
``install_langchain_monitoring`` and
|
|
18
|
+
``install_langgraph_monitoring`` wrap nothing. Instead they:
|
|
19
|
+
|
|
20
|
+
* configure otelzero's OTLP provider as the global one, which is where
|
|
21
|
+
LangSmith's exporter sends its spans;
|
|
22
|
+
* turn the export on through LangSmith's own variables:
|
|
23
|
+
``LANGSMITH_TRACING=true``, ``LANGSMITH_OTEL_ENABLED=true`` and, unless a
|
|
24
|
+
LangSmith API key is configured, ``LANGSMITH_OTEL_ONLY=true`` (so nothing is
|
|
25
|
+
sent to LangSmith's cloud). Unless content capture
|
|
26
|
+
(``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT``) is opted in,
|
|
27
|
+
``LANGSMITH_HIDE_INPUTS`` / ``LANGSMITH_HIDE_OUTPUTS`` are set too, because
|
|
28
|
+
LangSmith otherwise exports every prompt and completion as
|
|
29
|
+
``gen_ai.prompt`` / ``gen_ai.completion``. Values the user already set are
|
|
30
|
+
left alone, and an uninstall removes only the ones this module set.
|
|
31
|
+
* register a callback handler through LangChain's own
|
|
32
|
+
``register_configure_hook``. It isn't a wrapper: LangChain adds it to every
|
|
33
|
+
run's callbacks the way it adds its own tracers. It sends the events and
|
|
34
|
+
records each run's identity (below).
|
|
35
|
+
* attach the shared :class:`~otelzero.hooks._native.NativeRunProcessor`, with
|
|
36
|
+
events off, to stamp otelzero's identity on LangSmith's spans.
|
|
37
|
+
|
|
38
|
+
**Why the events come from callbacks, not spans.** LangSmith builds its spans
|
|
39
|
+
on a background thread, from the runs it has queued, once it flushes. A run
|
|
40
|
+
that has finished by then is created and ended in one go, before the spans of
|
|
41
|
+
its children exist. So the spans neither arrive live nor form a tree that is
|
|
42
|
+
open while they are processed, and :func:`~otelzero.context.run_context` is
|
|
43
|
+
not in scope on that thread. The callbacks fire in the caller's context, as
|
|
44
|
+
the run happens. Each top-level run (the outermost runnable, chain, graph or
|
|
45
|
+
agent a caller invoked) is one ``agent_start`` / ``agent_end`` /
|
|
46
|
+
``agent_error``. The model, the tools it called and its token usage are
|
|
47
|
+
collected from the model and tool runs under it. A graph's nodes and
|
|
48
|
+
subgraphs are part of the run, not runs of their own. The session is titled
|
|
49
|
+
from the run's input, as before.
|
|
50
|
+
|
|
51
|
+
**Identity.** A run's events and its spans name the same thread and user.
|
|
52
|
+
For the events, the identity is settled when the top-level run starts, in the
|
|
53
|
+
caller's context: what the run names in its metadata (``conversation_id`` /
|
|
54
|
+
``thread_id`` / ``session_id``, ``user_name`` / ``user_id`` / ``user``, the
|
|
55
|
+
keys :mod:`otelzero.translate.langsmith` maps), else the active
|
|
56
|
+
:func:`~otelzero.context.run_context`, else the client's defaults. A thread
|
|
57
|
+
named in the metadata keeps the surrounding scope's user and title. A span
|
|
58
|
+
can't be tied back to the run it records:
|
|
59
|
+
LangSmith starts its root spans under a fresh random trace and span id, on its
|
|
60
|
+
own thread, and sets no run id on them. So LangSmith's spans are stamped with
|
|
61
|
+
the *client's* identity (``user=``, ``session=``, the session title) when they
|
|
62
|
+
start, and the translator replaces it with the run's own wherever the run names
|
|
63
|
+
one in its metadata: ``config={"metadata": {"thread_id": ..., "user_name":
|
|
64
|
+
...}}``, or LangGraph's ``config={"configurable": {"thread_id": ...}}``, which
|
|
65
|
+
LangGraph copies into the metadata.
|
|
66
|
+
|
|
67
|
+
Both install functions share one handler and one processor, so installing
|
|
68
|
+
both reports each run once, under the framework label of the latest call.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
import os
|
|
72
|
+
import threading
|
|
73
|
+
import traceback as _traceback
|
|
74
|
+
from contextvars import ContextVar
|
|
75
|
+
from typing import Any, Dict, Optional
|
|
76
|
+
from uuid import UUID
|
|
77
|
+
|
|
78
|
+
from otelzero import otel, semconv
|
|
79
|
+
from otelzero.context import run_context
|
|
80
|
+
from otelzero.hooks import _native
|
|
81
|
+
from otelzero.hooks._usage import UsageAccumulator, extract_usage
|
|
82
|
+
from otelzero.telemetry import TelemetryClient
|
|
83
|
+
|
|
84
|
+
# One processor for every LangSmith span, whichever installer attached it.
|
|
85
|
+
_PROCESSOR_KEY = "LangSmith"
|
|
86
|
+
_SCOPES = ("langsmith",)
|
|
87
|
+
|
|
88
|
+
# The env var LangChain checks to instantiate the callback handler.
|
|
89
|
+
_HOOK_ENV = "OTELZERO_LANGCHAIN_CALLBACKS"
|
|
90
|
+
|
|
91
|
+
_lock = threading.Lock()
|
|
92
|
+
_telemetry: Optional[TelemetryClient] = None
|
|
93
|
+
# top-level run id -> what its runs revealed; any run id -> its top-level run.
|
|
94
|
+
_runs: Dict[UUID, dict] = {}
|
|
95
|
+
_root_of: Dict[UUID, UUID] = {}
|
|
96
|
+
_hook_registered = False
|
|
97
|
+
# Environment values this module set, so uninstall can put them back.
|
|
98
|
+
_set_env: dict = {}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- events from LangChain's callbacks -----------------------------------------
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _intent(value: Any) -> Optional[str]:
|
|
105
|
+
"""Best-effort intent text from a top-level run's inputs, to title the session."""
|
|
106
|
+
if isinstance(value, str):
|
|
107
|
+
return value
|
|
108
|
+
if isinstance(value, dict):
|
|
109
|
+
for key in ("input", "question", "query", "prompt"):
|
|
110
|
+
found = value.get(key)
|
|
111
|
+
if isinstance(found, str) and found:
|
|
112
|
+
return found
|
|
113
|
+
value = value.get("messages")
|
|
114
|
+
if isinstance(value, (list, tuple)):
|
|
115
|
+
for message in value:
|
|
116
|
+
if isinstance(message, list): # on_chat_model_start: [[messages]]
|
|
117
|
+
found = _intent(message)
|
|
118
|
+
if found:
|
|
119
|
+
return found
|
|
120
|
+
continue
|
|
121
|
+
if isinstance(message, tuple) and len(message) == 2 and message[0] in ("user", "human"):
|
|
122
|
+
return message[1] if isinstance(message[1], str) else None
|
|
123
|
+
if isinstance(message, dict) and message.get("role") in ("user", "human"):
|
|
124
|
+
content = message.get("content")
|
|
125
|
+
return content if isinstance(content, str) else None
|
|
126
|
+
if getattr(message, "type", None) == "human":
|
|
127
|
+
content = getattr(message, "content", None)
|
|
128
|
+
return content if isinstance(content, str) else None
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _model_of(serialized: Any, metadata: Any, invocation_params: Any) -> Optional[str]:
|
|
133
|
+
for source, key in (
|
|
134
|
+
(metadata, "ls_model_name"),
|
|
135
|
+
(invocation_params, "model"),
|
|
136
|
+
(invocation_params, "model_name"),
|
|
137
|
+
):
|
|
138
|
+
if isinstance(source, dict) and isinstance(source.get(key), str) and source[key]:
|
|
139
|
+
return source[key]
|
|
140
|
+
kwargs = serialized.get("kwargs") if isinstance(serialized, dict) else None
|
|
141
|
+
if isinstance(kwargs, dict):
|
|
142
|
+
for key in ("model", "model_name"):
|
|
143
|
+
if isinstance(kwargs.get(key), str) and kwargs[key]:
|
|
144
|
+
return kwargs[key]
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_name(serialized: Any, kwargs: dict, default: str) -> str:
|
|
149
|
+
name = kwargs.get("name")
|
|
150
|
+
if isinstance(name, str) and name:
|
|
151
|
+
return name
|
|
152
|
+
if isinstance(serialized, dict):
|
|
153
|
+
if isinstance(serialized.get("name"), str) and serialized["name"]:
|
|
154
|
+
return serialized["name"]
|
|
155
|
+
ids = serialized.get("id")
|
|
156
|
+
if isinstance(ids, list) and ids and isinstance(ids[-1], str):
|
|
157
|
+
return ids[-1]
|
|
158
|
+
return default
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _usage_of(response: Any) -> Optional[dict]:
|
|
162
|
+
"""Token usage of one model call, from its ``LLMResult``."""
|
|
163
|
+
for generations in getattr(response, "generations", None) or []:
|
|
164
|
+
for generation in generations or []:
|
|
165
|
+
message = getattr(generation, "message", None)
|
|
166
|
+
usage = extract_usage(getattr(message, "usage_metadata", None))
|
|
167
|
+
if usage:
|
|
168
|
+
return usage
|
|
169
|
+
llm_output = getattr(response, "llm_output", None)
|
|
170
|
+
if isinstance(llm_output, dict):
|
|
171
|
+
return extract_usage(llm_output.get("token_usage") or llm_output.get("usage"))
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _metadata_identity(metadata: Any):
|
|
176
|
+
"""``(user, conversation_id)`` a run names in its metadata, else ``None``s.
|
|
177
|
+
|
|
178
|
+
The same keys, in the same order, that :mod:`otelzero.translate.langsmith`
|
|
179
|
+
maps onto the run's spans, so a run's events and spans agree. LangGraph
|
|
180
|
+
copies ``config["configurable"]["thread_id"]`` into the metadata, so a
|
|
181
|
+
LangGraph thread counts too. Imported lazily: ``otelzero.translate`` needs
|
|
182
|
+
the OpenTelemetry SDK, and this module is imported with ``otelzero``.
|
|
183
|
+
"""
|
|
184
|
+
if not isinstance(metadata, dict) or not metadata:
|
|
185
|
+
return None, None
|
|
186
|
+
from otelzero.translate.langsmith import CONVERSATION_KEYS, USER_KEYS
|
|
187
|
+
|
|
188
|
+
def first(keys):
|
|
189
|
+
for key in keys:
|
|
190
|
+
value = metadata.get(key)
|
|
191
|
+
if value is not None and value != "":
|
|
192
|
+
return str(value)
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
return first(USER_KEYS), first(CONVERSATION_KEYS)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _send(telemetry: TelemetryClient, run: dict, **fields: Any) -> None:
|
|
199
|
+
"""Send one of a run's events under the identity it started with."""
|
|
200
|
+
user, conversation_id, session_name = run["identity"]
|
|
201
|
+
with run_context(user=user, conversation_id=conversation_id, name=session_name):
|
|
202
|
+
telemetry.send_log(**fields)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _start(
|
|
206
|
+
run_id: Any,
|
|
207
|
+
parent_run_id: Any,
|
|
208
|
+
name: str,
|
|
209
|
+
inputs: Any,
|
|
210
|
+
model: Optional[str],
|
|
211
|
+
metadata: Any = None,
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Register a run; a top-level one also sends ``agent_start``.
|
|
214
|
+
|
|
215
|
+
The run's identity is settled here, in the caller's context: what the run
|
|
216
|
+
names in its metadata, else the active ``run_context``, else the client's
|
|
217
|
+
defaults. All of the run's events carry it.
|
|
218
|
+
"""
|
|
219
|
+
telemetry = _telemetry
|
|
220
|
+
if telemetry is None or not isinstance(run_id, UUID):
|
|
221
|
+
return
|
|
222
|
+
with _lock:
|
|
223
|
+
if parent_run_id is not None:
|
|
224
|
+
root = _root_of.get(parent_run_id)
|
|
225
|
+
if root is not None:
|
|
226
|
+
_root_of[run_id] = root
|
|
227
|
+
run = _runs.get(root)
|
|
228
|
+
if run is not None and model and not run["model"]:
|
|
229
|
+
run["model"] = model
|
|
230
|
+
return
|
|
231
|
+
_root_of[run_id] = run_id
|
|
232
|
+
run = _runs[run_id] = {
|
|
233
|
+
"name": name,
|
|
234
|
+
"model": model,
|
|
235
|
+
"tools": set(),
|
|
236
|
+
"usage": UsageAccumulator(),
|
|
237
|
+
}
|
|
238
|
+
meta_user, meta_conversation = _metadata_identity(metadata)
|
|
239
|
+
user, conversation_id, session_name = telemetry.run_identity()
|
|
240
|
+
if meta_conversation is not None:
|
|
241
|
+
# The run's own thread, inside whatever scope it was started in: keep
|
|
242
|
+
# that scope's user and title (an explicit ``name=`` included), and
|
|
243
|
+
# title the thread from this run's input only if there is none yet.
|
|
244
|
+
with run_context(user=user, conversation_id=meta_conversation, name=session_name):
|
|
245
|
+
telemetry.suggest_session_name(_intent(inputs))
|
|
246
|
+
_, _, session_name = telemetry.run_identity()
|
|
247
|
+
conversation_id = meta_conversation
|
|
248
|
+
else:
|
|
249
|
+
telemetry.suggest_session_name(_intent(inputs))
|
|
250
|
+
user, conversation_id, session_name = telemetry.run_identity()
|
|
251
|
+
run["identity"] = (meta_user or user, conversation_id, session_name)
|
|
252
|
+
_send(
|
|
253
|
+
telemetry,
|
|
254
|
+
run,
|
|
255
|
+
event_type="agent_start",
|
|
256
|
+
agent_name=name,
|
|
257
|
+
agent_role=name,
|
|
258
|
+
model=model,
|
|
259
|
+
details={"model": model},
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _end(run_id: Any, error: Optional[BaseException] = None) -> None:
|
|
264
|
+
"""Forget a run; a top-level one also sends ``agent_end`` / ``agent_error``."""
|
|
265
|
+
with _lock:
|
|
266
|
+
root = _root_of.pop(run_id, None)
|
|
267
|
+
run = _runs.pop(run_id, None) if root == run_id else None
|
|
268
|
+
if run is not None:
|
|
269
|
+
for child in [k for k, v in _root_of.items() if v == run_id]:
|
|
270
|
+
del _root_of[child]
|
|
271
|
+
telemetry = _telemetry
|
|
272
|
+
if run is None or telemetry is None:
|
|
273
|
+
return
|
|
274
|
+
fields = {"agent_name": run["name"], "agent_role": run["name"], "model": run["model"]}
|
|
275
|
+
usage = run["usage"].result()
|
|
276
|
+
if error is not None:
|
|
277
|
+
_send(
|
|
278
|
+
telemetry,
|
|
279
|
+
run,
|
|
280
|
+
event_type="agent_error",
|
|
281
|
+
error=str(error) or type(error).__name__,
|
|
282
|
+
traceback="".join(
|
|
283
|
+
_traceback.format_exception(type(error), error, error.__traceback__)
|
|
284
|
+
),
|
|
285
|
+
details={"usage": usage} if usage else None,
|
|
286
|
+
**fields,
|
|
287
|
+
)
|
|
288
|
+
return
|
|
289
|
+
details: dict = {"status": "success"}
|
|
290
|
+
if run["tools"]:
|
|
291
|
+
details["tools"] = sorted(run["tools"])
|
|
292
|
+
if usage:
|
|
293
|
+
details["usage"] = usage
|
|
294
|
+
_send(telemetry, run, event_type="agent_end", details=details, **fields)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _root_run(run_id: Any) -> Optional[dict]:
|
|
298
|
+
with _lock:
|
|
299
|
+
root = _root_of.get(run_id)
|
|
300
|
+
return _runs.get(root) if root is not None else None
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _handler_class():
|
|
304
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
305
|
+
|
|
306
|
+
class OtelzeroRunEvents(BaseCallbackHandler):
|
|
307
|
+
"""Sends otelzero's ``agent_*`` events for top-level runs; changes nothing."""
|
|
308
|
+
|
|
309
|
+
# Inline, so the sync callbacks run in the caller's context (where
|
|
310
|
+
# run_context is) even under an async run.
|
|
311
|
+
run_inline = True
|
|
312
|
+
raise_error = False
|
|
313
|
+
|
|
314
|
+
# -- starts ---------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
def on_chain_start(
|
|
317
|
+
self, serialized, inputs, *, run_id, parent_run_id=None, metadata=None, **kwargs
|
|
318
|
+
):
|
|
319
|
+
name = _run_name(serialized, kwargs, "chain")
|
|
320
|
+
_start(run_id, parent_run_id, name, inputs, None, metadata)
|
|
321
|
+
|
|
322
|
+
def on_chat_model_start(
|
|
323
|
+
self, serialized, messages, *, run_id, parent_run_id=None, metadata=None, **kwargs
|
|
324
|
+
):
|
|
325
|
+
model = _model_of(serialized, metadata, kwargs.get("invocation_params"))
|
|
326
|
+
name = _run_name(serialized, kwargs, "chat_model")
|
|
327
|
+
_start(run_id, parent_run_id, name, messages, model, metadata)
|
|
328
|
+
|
|
329
|
+
def on_llm_start(
|
|
330
|
+
self, serialized, prompts, *, run_id, parent_run_id=None, metadata=None, **kwargs
|
|
331
|
+
):
|
|
332
|
+
model = _model_of(serialized, metadata, kwargs.get("invocation_params"))
|
|
333
|
+
name = _run_name(serialized, kwargs, "llm")
|
|
334
|
+
_start(run_id, parent_run_id, name, prompts, model, metadata)
|
|
335
|
+
|
|
336
|
+
def on_tool_start(
|
|
337
|
+
self, serialized, input_str, *, run_id, parent_run_id=None, metadata=None, **kwargs
|
|
338
|
+
):
|
|
339
|
+
name = _run_name(serialized, kwargs, "tool")
|
|
340
|
+
_start(run_id, parent_run_id, name, input_str, None, metadata)
|
|
341
|
+
run = _root_run(run_id)
|
|
342
|
+
if run is not None:
|
|
343
|
+
with _lock:
|
|
344
|
+
run["tools"].add(name)
|
|
345
|
+
|
|
346
|
+
def on_retriever_start(
|
|
347
|
+
self, serialized, query, *, run_id, parent_run_id=None, metadata=None, **kwargs
|
|
348
|
+
):
|
|
349
|
+
name = _run_name(serialized, kwargs, "retriever")
|
|
350
|
+
_start(run_id, parent_run_id, name, query, None, metadata)
|
|
351
|
+
|
|
352
|
+
# -- ends -----------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
def on_llm_end(self, response, *, run_id, **kwargs):
|
|
355
|
+
run = _root_run(run_id)
|
|
356
|
+
if run is not None:
|
|
357
|
+
usage = _usage_of(response)
|
|
358
|
+
with _lock:
|
|
359
|
+
run["usage"].add(usage)
|
|
360
|
+
_end(run_id)
|
|
361
|
+
|
|
362
|
+
def on_chain_end(self, outputs, *, run_id, **kwargs):
|
|
363
|
+
_end(run_id)
|
|
364
|
+
|
|
365
|
+
def on_tool_end(self, output, *, run_id, **kwargs):
|
|
366
|
+
_end(run_id)
|
|
367
|
+
|
|
368
|
+
def on_retriever_end(self, documents, *, run_id, **kwargs):
|
|
369
|
+
_end(run_id)
|
|
370
|
+
|
|
371
|
+
def on_chain_error(self, error, *, run_id, **kwargs):
|
|
372
|
+
_end(run_id, error)
|
|
373
|
+
|
|
374
|
+
def on_llm_error(self, error, *, run_id, **kwargs):
|
|
375
|
+
_end(run_id, error)
|
|
376
|
+
|
|
377
|
+
def on_tool_error(self, error, *, run_id, **kwargs):
|
|
378
|
+
_end(run_id, error)
|
|
379
|
+
|
|
380
|
+
def on_retriever_error(self, error, *, run_id, **kwargs):
|
|
381
|
+
_end(run_id, error)
|
|
382
|
+
|
|
383
|
+
return OtelzeroRunEvents
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _register_handler() -> None:
|
|
387
|
+
global _hook_registered
|
|
388
|
+
if _hook_registered:
|
|
389
|
+
return
|
|
390
|
+
from langchain_core.tracers.context import register_configure_hook
|
|
391
|
+
|
|
392
|
+
register_configure_hook(
|
|
393
|
+
ContextVar("otelzero_run_events", default=None),
|
|
394
|
+
True,
|
|
395
|
+
_handler_class(),
|
|
396
|
+
_HOOK_ENV,
|
|
397
|
+
)
|
|
398
|
+
_hook_registered = True
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
# --- LangSmith's export ------------------------------------------------------
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _setdefault_env(name: str, value: str) -> None:
|
|
405
|
+
if name in os.environ:
|
|
406
|
+
return
|
|
407
|
+
os.environ[name] = value
|
|
408
|
+
_set_env[name] = value
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _enable_export() -> None:
|
|
412
|
+
_setdefault_env("LANGSMITH_TRACING", "true")
|
|
413
|
+
_setdefault_env("LANGSMITH_OTEL_ENABLED", "true")
|
|
414
|
+
if not (os.environ.get("LANGSMITH_API_KEY") or os.environ.get("LANGCHAIN_API_KEY")):
|
|
415
|
+
# Nothing to send to LangSmith's cloud: export over OTel only.
|
|
416
|
+
_setdefault_env("LANGSMITH_OTEL_ONLY", "true")
|
|
417
|
+
if not semconv.capture_content_enabled():
|
|
418
|
+
_setdefault_env("LANGSMITH_HIDE_INPUTS", "true")
|
|
419
|
+
_setdefault_env("LANGSMITH_HIDE_OUTPUTS", "true")
|
|
420
|
+
os.environ[_HOOK_ENV] = "true"
|
|
421
|
+
_clear_langsmith_env_cache()
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _clear_langsmith_env_cache() -> None:
|
|
425
|
+
"""LangSmith caches env lookups; drop them so the new values are read."""
|
|
426
|
+
try:
|
|
427
|
+
from langsmith import utils as ls_utils
|
|
428
|
+
except ImportError: # pragma: no cover - checked by install
|
|
429
|
+
return
|
|
430
|
+
for name in ("get_env_var", "tracing_is_enabled", "get_tracer_project"):
|
|
431
|
+
cache_clear = getattr(getattr(ls_utils, name, None), "cache_clear", None)
|
|
432
|
+
if callable(cache_clear):
|
|
433
|
+
cache_clear()
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def install(
|
|
437
|
+
framework: str,
|
|
438
|
+
server_url: str,
|
|
439
|
+
user: Optional[str],
|
|
440
|
+
api_key: Optional[str],
|
|
441
|
+
session: Optional[str],
|
|
442
|
+
name: Optional[str],
|
|
443
|
+
) -> TelemetryClient:
|
|
444
|
+
"""Shared body of ``install_langchain_monitoring`` / ``install_langgraph_monitoring``."""
|
|
445
|
+
global _telemetry
|
|
446
|
+
try:
|
|
447
|
+
import langchain_core # noqa: F401
|
|
448
|
+
import langsmith # noqa: F401
|
|
449
|
+
except ImportError as exc: # pragma: no cover - exercised by hand
|
|
450
|
+
raise ImportError(
|
|
451
|
+
f"{framework} monitoring needs langchain-core and langsmith importable: "
|
|
452
|
+
"uv pip install langchain-core 'langsmith[otel]'"
|
|
453
|
+
) from exc
|
|
454
|
+
|
|
455
|
+
telemetry = TelemetryClient(
|
|
456
|
+
server_url,
|
|
457
|
+
framework=framework,
|
|
458
|
+
user=user,
|
|
459
|
+
api_key=api_key,
|
|
460
|
+
session=session,
|
|
461
|
+
name=name,
|
|
462
|
+
)
|
|
463
|
+
provider = _native.start_tracing(framework, telemetry, server_url)
|
|
464
|
+
_native.attach_processor(
|
|
465
|
+
_native.global_provider(provider, framework),
|
|
466
|
+
telemetry,
|
|
467
|
+
framework=framework,
|
|
468
|
+
scopes=_SCOPES,
|
|
469
|
+
default_agent_name=f"{framework} run",
|
|
470
|
+
send_events=False,
|
|
471
|
+
key=_PROCESSOR_KEY,
|
|
472
|
+
)
|
|
473
|
+
_telemetry = telemetry
|
|
474
|
+
_enable_export()
|
|
475
|
+
_register_handler()
|
|
476
|
+
|
|
477
|
+
print(
|
|
478
|
+
f"[MONITOR] {framework} monitoring installed via LangSmith's native "
|
|
479
|
+
f"OpenTelemetry export (server: {server_url})"
|
|
480
|
+
)
|
|
481
|
+
return telemetry
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def uninstall() -> None:
|
|
485
|
+
"""Stop the events and span stamping; undo the env vars this module set.
|
|
486
|
+
|
|
487
|
+
The callback handler stays registered (LangChain offers no way to remove a
|
|
488
|
+
configure hook) but is no longer instantiated, and is dormant if it is.
|
|
489
|
+
"""
|
|
490
|
+
global _telemetry
|
|
491
|
+
from opentelemetry import trace
|
|
492
|
+
|
|
493
|
+
_native.detach(_PROCESSOR_KEY, otel._provider)
|
|
494
|
+
_native.detach(_PROCESSOR_KEY, trace.get_tracer_provider())
|
|
495
|
+
_telemetry = None
|
|
496
|
+
os.environ.pop(_HOOK_ENV, None)
|
|
497
|
+
for env_name, value in list(_set_env.items()):
|
|
498
|
+
if os.environ.get(env_name) == value:
|
|
499
|
+
del os.environ[env_name]
|
|
500
|
+
del _set_env[env_name]
|
|
501
|
+
with _lock:
|
|
502
|
+
_runs.clear()
|
|
503
|
+
_root_of.clear()
|
|
504
|
+
_clear_langsmith_env_cache()
|