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,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()