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,260 @@
1
+ """Databricks Agent Bricks / Mosaic AI instrumentation for MLflow agents."""
2
+
3
+ from typing import Optional
4
+
5
+ import wrapt
6
+
7
+ from otelzero import semconv
8
+ from otelzero.hooks import _content
9
+ from otelzero.hooks._usage import _field, extract_usage
10
+ from otelzero.hooks.base import _ImportInstrumenter, install_hook, trace_agent_run
11
+
12
+ # Cap free-text task detail (tool arguments / output) so a large payload can't
13
+ # bloat every task event; mirrors the [:100] truncation the other hooks use.
14
+ _MAX_DETAIL = 200
15
+
16
+
17
+ def _truncate(value):
18
+ """Coerce to a short string for a telemetry ``details`` field (or ``None``)."""
19
+ if value is None:
20
+ return None
21
+ text = value if isinstance(value, str) else str(value)
22
+ return text if len(text) <= _MAX_DETAIL else text[:_MAX_DETAIL] + "…"
23
+
24
+
25
+ def _first_present(obj, *keys):
26
+ """First of ``keys`` that reads as a non-``None`` field off ``obj``."""
27
+ for key in keys:
28
+ value = _field(obj, key)
29
+ if value is not None:
30
+ return value
31
+ return None
32
+
33
+
34
+ def _emit_tool_task_events(telemetry, agent_name, response):
35
+ """Surface each tool a ResponsesAgent called as a otelzero task on its timeline.
36
+
37
+ A ``ResponsesAgent`` records its tool calls in ``response.output`` as
38
+ ``function_call`` items, with results in matching ``function_call_output``
39
+ items (paired by ``call_id``). Each call is a unit of work the run did, so we
40
+ emit a task_start/task_end pair for it -- retrospectively, since the tool has
41
+ already executed inside ``predict`` (the ``with`` body is empty; we're
42
+ recording the completed call, not wrapping a live one). ``agent_role`` is set
43
+ to the agent name so the task attaches to that agent's card rather than
44
+ spawning its own (see ``kiji_agent_registry.db``'s task-event guard).
45
+
46
+ Responses without an ``output`` list -- a plain-text answer, or a ``ChatAgent``
47
+ (whose response carries ``messages``, not ``output``) -- yield no tasks.
48
+ """
49
+ output = _field(response, "output")
50
+ if not isinstance(output, (list, tuple)) or not output:
51
+ return
52
+
53
+ # Index tool results by call_id so each task_end can report its outcome.
54
+ results = {
55
+ _field(it, "call_id"): it
56
+ for it in output
57
+ if _field(it, "type") == "function_call_output"
58
+ }
59
+
60
+ for item in output:
61
+ if _field(item, "type") != "function_call":
62
+ continue
63
+ tool = _field(item, "name") or "tool"
64
+ call_id = _field(item, "call_id")
65
+ start_details = {
66
+ "tool": tool,
67
+ "call_id": call_id,
68
+ "arguments": _truncate(_field(item, "arguments")),
69
+ }
70
+ end_details = {"status": "success"}
71
+ result = results.get(call_id)
72
+ if result is not None:
73
+ end_details["output"] = _truncate(_field(result, "output"))
74
+
75
+ with trace_agent_run(
76
+ telemetry,
77
+ semconv.span_name(semconv.OP_EXECUTE_TOOL, tool),
78
+ {
79
+ semconv.GEN_AI_OPERATION_NAME: semconv.OP_EXECUTE_TOOL,
80
+ semconv.GEN_AI_AGENT_NAME: agent_name,
81
+ semconv.GEN_AI_TOOL_NAME: tool,
82
+ semconv.GEN_AI_TOOL_CALL_ID: call_id,
83
+ },
84
+ start_event="task_start",
85
+ end_event="task_end",
86
+ error_event="task_error",
87
+ log_fields={"agent_name": agent_name, "agent_role": agent_name},
88
+ start_details=start_details,
89
+ end_details=end_details,
90
+ ):
91
+ pass
92
+
93
+
94
+ class DatabricksImportHook(_ImportInstrumenter):
95
+ """Instrument Databricks Agent Bricks / Mosaic AI agents.
96
+
97
+ Databricks agents (Agent Bricks, the Mosaic AI Agent Framework) are authored
98
+ as MLflow agent subclasses -- ``mlflow.pyfunc.ResponsesAgent`` (the current
99
+ recommended interface) or ``mlflow.pyfunc.ChatAgent`` (its predecessor).
100
+ Both live in ``mlflow.pyfunc.model``, both declare ``predict`` as an
101
+ *abstract* method each agent implements, and both drive subclass validation
102
+ through ``__init_subclass__`` -- so, unlike a concrete framework class, there
103
+ is no single method on the base to wrap. Instead this hook intercepts
104
+ subclass creation (``__init_subclass__``) for each base and wraps each
105
+ concrete agent's ``predict`` as it is defined (and any subclasses already
106
+ defined when the module loads), composing on top of MLflow's own validation
107
+ wrapper. Calling ``agent.predict(...)`` -- directly or via MLflow serving --
108
+ then streams agent telemetry with zero changes to the agent code.
109
+
110
+ Each ``predict`` call emits agent_start/agent_end/agent_error. On top of that,
111
+ the tools a ``ResponsesAgent`` invokes -- recorded in its response as
112
+ ``function_call`` items (with results in matching ``function_call_output``
113
+ items) -- are surfaced as nested task_start/task_end events, one per tool, so
114
+ a run's tool steps show up on the agent's timeline (see
115
+ :func:`_emit_tool_task_events`).
116
+
117
+ Note: only ``predict`` is wrapped (the required entry point). ``predict_stream``
118
+ is left alone to avoid double-counting agents whose ``predict`` aggregates
119
+ ``predict_stream`` internally.
120
+ """
121
+
122
+ WATCHED = {"mlflow.pyfunc.model": "_wrap_agent_bases"}
123
+
124
+ # otelzero's own marker so we never double-wrap (MLflow already sets __wrapped__).
125
+ _MARKER = "_otelzero_wrapped"
126
+
127
+ # The MLflow agent-authoring bases to instrument, both in mlflow.pyfunc.model.
128
+ _AGENT_BASES = ("ResponsesAgent", "ChatAgent")
129
+
130
+ def _wrap_agent_bases(self, module):
131
+ for base_name in self._AGENT_BASES:
132
+ agent_base = getattr(module, base_name, None)
133
+ if agent_base is not None:
134
+ self._instrument_base(agent_base)
135
+
136
+ def _instrument_base(self, agent_base):
137
+ telemetry = self.telemetry
138
+
139
+ @wrapt.decorator
140
+ def predict_wrapper(wrapped, instance, args, kwargs):
141
+ agent_name = type(instance).__name__
142
+ # ChatAgent.predict(messages, ...) receives the message list
143
+ # directly; ResponsesAgent.predict(request) wraps it in
144
+ # ``request.input`` -- unwrap that so both count correctly.
145
+ arg = args[0] if args else kwargs.get("messages", kwargs.get("request"))
146
+ payload = getattr(arg, "input", arg)
147
+ try:
148
+ num_messages = len(payload)
149
+ except TypeError:
150
+ num_messages = 0
151
+ agent_info = {"num_messages": num_messages}
152
+
153
+ with trace_agent_run(
154
+ telemetry,
155
+ semconv.span_name(semconv.OP_INVOKE_AGENT, agent_name),
156
+ {
157
+ semconv.GEN_AI_OPERATION_NAME: semconv.OP_INVOKE_AGENT,
158
+ semconv.GEN_AI_AGENT_NAME: agent_name,
159
+ semconv.OTELZERO_NUM_MESSAGES: num_messages,
160
+ },
161
+ start_event="agent_start",
162
+ end_event="agent_end",
163
+ error_event="agent_error",
164
+ log_fields={"agent_name": agent_name, "agent_role": agent_name},
165
+ start_details=agent_info,
166
+ ) as run:
167
+ # ChatAgent messages ({role, content}) or ResponsesAgent input
168
+ # items (message / function_call / function_call_output).
169
+ run.set_input(_content.messages_from(payload))
170
+ response = wrapped(*args, **kwargs)
171
+ # Surface the tools this run called as nested task events. Emitted
172
+ # inside the agent span (so they attach to its trace/timeline)
173
+ # and after predict returns (the calls have already run).
174
+ _emit_tool_task_events(telemetry, agent_name, response)
175
+ # Responses carry OpenAI-style usage (input/output tokens plus
176
+ # ``*_tokens_details.reasoning_tokens`` when reported).
177
+ run.set_usage(extract_usage(_field(response, "usage")))
178
+ # ChatAgentResponse.messages, or ResponsesAgentResponse.output
179
+ # items (the same message / tool item shapes as the input).
180
+ run.set_output(
181
+ _content.messages_from(
182
+ _first_present(response, "messages", "output"),
183
+ default_role="assistant",
184
+ )
185
+ or _content.text_of(response)
186
+ )
187
+ return response
188
+
189
+ marker = self._MARKER
190
+
191
+ def _wrap_predict(cls):
192
+ predict = cls.__dict__.get("predict")
193
+ if (
194
+ predict is None
195
+ or getattr(predict, "__isabstractmethod__", False)
196
+ or getattr(predict, marker, False)
197
+ ):
198
+ return
199
+ wrapped = predict_wrapper(predict)
200
+ setattr(wrapped, marker, True)
201
+ cls.predict = wrapped
202
+
203
+ # Wrap concrete subclasses that already exist when the module loads ...
204
+ def _walk(cls):
205
+ for sub in cls.__subclasses__():
206
+ _wrap_predict(sub)
207
+ _walk(sub)
208
+
209
+ _walk(agent_base)
210
+
211
+ # ... and intercept every future subclass definition. ``__init_subclass__``
212
+ # is implicitly a classmethod; chain to the previous one (MLflow installs
213
+ # its data-validation wrapper there) before applying ours on top.
214
+ prev_init_subclass = agent_base.__dict__.get("__init_subclass__")
215
+
216
+ def _patched_init_subclass(cls, **kwargs):
217
+ if prev_init_subclass is not None:
218
+ prev_init_subclass.__func__(cls, **kwargs)
219
+ else:
220
+ super(agent_base, cls).__init_subclass__(**kwargs)
221
+ _wrap_predict(cls)
222
+
223
+ agent_base.__init_subclass__ = classmethod(_patched_init_subclass)
224
+
225
+
226
+ def install_databricks_monitoring(
227
+ server_url: str = "http://localhost:8000",
228
+ user: Optional[str] = None,
229
+ api_key: Optional[str] = None,
230
+ session: Optional[str] = None,
231
+ name: Optional[str] = None,
232
+ ):
233
+ """
234
+ Install Databricks Agent Bricks / Mosaic AI monitoring instrumentation.
235
+
236
+ Instruments MLflow ``ResponsesAgent`` and ``ChatAgent`` subclasses -- the
237
+ authoring interfaces for Databricks Agent Bricks and the Mosaic AI Agent
238
+ Framework -- by wrapping each concrete agent's ``predict`` as it is defined.
239
+ Every ``agent.predict(...)`` call (direct or via MLflow serving) streams
240
+ agent_start/agent_end/agent_error telemetry with zero changes to the agent
241
+ code. Call this *before* ``mlflow.pyfunc`` is imported (i.e. before defining
242
+ your agent subclass).
243
+
244
+ Args:
245
+ server_url: URL of the monitoring server
246
+ user: Name of the executing user
247
+ api_key: Ingest key tying events to an owner (for the iOS app)
248
+ session: Per-run id used to group events in the app
249
+ name: Explicit session title (else falls back to a codename)
250
+ """
251
+ return install_hook(
252
+ DatabricksImportHook,
253
+ framework="Databricks Agents",
254
+ label="Databricks Agent Bricks",
255
+ server_url=server_url,
256
+ user=user,
257
+ api_key=api_key,
258
+ session=session,
259
+ name=name,
260
+ )
@@ -0,0 +1,56 @@
1
+ """LangChain instrumentation: switch on LangSmith's *own* OpenTelemetry export.
2
+
3
+ LangChain traces itself through LangSmith, and ``langsmith[otel]`` exports
4
+ those traces as OpenTelemetry spans. Every chain, runnable, agent, model and
5
+ tool call becomes a span named after the run, carrying ``langsmith.span.kind``,
6
+ ``gen_ai.operation.name``, ``gen_ai.request.model`` and ``gen_ai.usage.*``.
7
+ That is more than wrapping ``invoke`` from the outside could see, so this hook
8
+ wraps nothing: it points the export at the registry and derives otelzero's
9
+ events from it. See :mod:`otelzero.hooks._langsmith` for the details, shared
10
+ with :mod:`otelzero.hooks.langgraph`.
11
+ """
12
+
13
+ from typing import Optional
14
+
15
+ from otelzero.hooks import _langsmith
16
+ from otelzero.telemetry import TelemetryClient
17
+
18
+ FRAMEWORK = "LangChain"
19
+
20
+
21
+ def install_langchain_monitoring(
22
+ server_url: str = "http://localhost:8000",
23
+ user: Optional[str] = None,
24
+ api_key: Optional[str] = None,
25
+ session: Optional[str] = None,
26
+ name: Optional[str] = None,
27
+ ) -> TelemetryClient:
28
+ """
29
+ Install LangChain monitoring via LangSmith's native OpenTelemetry export.
30
+
31
+ Exports LangChain's own LangSmith spans to ``{server_url}/v1/traces`` (or
32
+ to ``OTEL_EXPORTER_OTLP_ENDPOINT`` when set), translated into the current
33
+ GenAI conventions on export (:mod:`otelzero.translate`), and
34
+ sends ``agent_start`` and ``agent_end`` (or ``agent_error``) for each
35
+ top-level run: a chain, a runnable, or a LangChain 1.0 ``create_agent``
36
+ agent (a LangGraph graph). May be called before or after ``langchain`` is
37
+ imported; requires ``langsmith[otel]`` and the ``otel`` extra.
38
+
39
+ Args:
40
+ server_url: URL of the monitoring server
41
+ user: Name of the executing user
42
+ api_key: Ingest key tying events to an owner (for the iOS app)
43
+ session: Per-run id used to group events in the app; exported on every
44
+ span as ``gen_ai.conversation.id``
45
+ name: Explicit session title (else derived from the run's input)
46
+
47
+ Raises:
48
+ ImportError: ``langchain-core`` or ``langsmith`` is not installed.
49
+ RuntimeError: the OpenTelemetry SDK (the ``otel`` extra) is missing.
50
+ """
51
+ return _langsmith.install(FRAMEWORK, server_url, user, api_key, session, name)
52
+
53
+
54
+ def uninstall_langchain_monitoring() -> None:
55
+ """Stop the events; undo the LangSmith env vars the install set."""
56
+ _langsmith.uninstall()
@@ -0,0 +1,57 @@
1
+ """LangGraph instrumentation: switch on LangSmith's *own* OpenTelemetry export.
2
+
3
+ LangGraph traces itself through LangSmith like the rest of LangChain: a graph
4
+ run is a LangSmith run, each node and subgraph a child run, and every model and
5
+ tool call a run below those. With ``langsmith[otel]`` each becomes an
6
+ OpenTelemetry span. LangChain 1.0's ``create_agent`` and ``create_react_agent``
7
+ return such graphs. This hook wraps nothing: it points the export at the
8
+ registry and turns each graph run (the trace's root span) into
9
+ ``agent_start`` / ``agent_end`` / ``agent_error``. Its nodes and subgraphs are
10
+ part of the run, not runs of their own. See :mod:`otelzero.hooks._langsmith`
11
+ for the details, shared with :mod:`otelzero.hooks.langchain`.
12
+ """
13
+
14
+ from typing import Optional
15
+
16
+ from otelzero.hooks import _langsmith
17
+ from otelzero.telemetry import TelemetryClient
18
+
19
+ FRAMEWORK = "LangGraph"
20
+
21
+
22
+ def install_langgraph_monitoring(
23
+ server_url: str = "http://localhost:8000",
24
+ user: Optional[str] = None,
25
+ api_key: Optional[str] = None,
26
+ session: Optional[str] = None,
27
+ name: Optional[str] = None,
28
+ ) -> TelemetryClient:
29
+ """
30
+ Install LangGraph monitoring via LangSmith's native OpenTelemetry export.
31
+
32
+ Exports LangGraph's own LangSmith spans to ``{server_url}/v1/traces`` (or
33
+ to ``OTEL_EXPORTER_OTLP_ENDPOINT`` when set), translated into the current
34
+ GenAI conventions on export (:mod:`otelzero.translate`), and
35
+ sends ``agent_start`` and ``agent_end`` (or ``agent_error``) for each graph
36
+ run. May be called before or after ``langgraph`` is imported; requires
37
+ ``langsmith[otel]`` and the ``otel`` extra. Installing this and
38
+ ``install_langchain_monitoring`` together still reports each run once.
39
+
40
+ Args:
41
+ server_url: URL of the monitoring server
42
+ user: Name of the executing user
43
+ api_key: Ingest key tying events to an owner (for the iOS app)
44
+ session: Per-run id used to group events in the app; exported on every
45
+ span as ``gen_ai.conversation.id``
46
+ name: Explicit session title (else derived from the graph's input)
47
+
48
+ Raises:
49
+ ImportError: ``langchain-core`` or ``langsmith`` is not installed.
50
+ RuntimeError: the OpenTelemetry SDK (the ``otel`` extra) is missing.
51
+ """
52
+ return _langsmith.install(FRAMEWORK, server_url, user, api_key, session, name)
53
+
54
+
55
+ def uninstall_langgraph_monitoring() -> None:
56
+ """Stop the events; undo the LangSmith env vars the install set."""
57
+ _langsmith.uninstall()