agent-observability-trace-cli 0.1.2__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.
- agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
- agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
- agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
- agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
- agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
- agent_trace/__init__.py +1182 -0
- agent_trace/_cli.py +1377 -0
- agent_trace/_inspect.py +2020 -0
- agent_trace/_replay/__init__.py +1 -0
- agent_trace/_replay/engine.py +268 -0
- agent_trace/_replay/fixture.py +868 -0
- agent_trace/core/__init__.py +1 -0
- agent_trace/core/clock.py +91 -0
- agent_trace/core/exceptions.py +51 -0
- agent_trace/core/span.py +271 -0
- agent_trace/core/trace.py +92 -0
- agent_trace/exporters/__init__.py +1 -0
- agent_trace/exporters/file.py +80 -0
- agent_trace/exporters/otlp.py +199 -0
- agent_trace/exporters/remote_fixture.py +307 -0
- agent_trace/exporters/stdout.py +258 -0
- agent_trace/integrations/__init__.py +69 -0
- agent_trace/integrations/agno.py +489 -0
- agent_trace/integrations/autogen.py +581 -0
- agent_trace/integrations/crewai.py +486 -0
- agent_trace/integrations/google_genai.py +731 -0
- agent_trace/integrations/haystack.py +254 -0
- agent_trace/integrations/langchain_core.py +361 -0
- agent_trace/integrations/langgraph.py +2093 -0
- agent_trace/integrations/langgraph_checkpoint.py +763 -0
- agent_trace/integrations/langgraph_state_diff.py +345 -0
- agent_trace/integrations/langgraph_stream_debug.py +202 -0
- agent_trace/integrations/llama_index.py +472 -0
- agent_trace/integrations/mcp.py +281 -0
- agent_trace/integrations/openai_agents.py +811 -0
- agent_trace/integrations/pydantic_ai.py +504 -0
- agent_trace/integrations/streaming.py +218 -0
- agent_trace/interceptor/__init__.py +64 -0
- agent_trace/interceptor/aiohttp_hook.py +152 -0
- agent_trace/interceptor/botocore_hook.py +223 -0
- agent_trace/interceptor/grpc_hook.py +651 -0
- agent_trace/interceptor/httpx_hook.py +866 -0
- agent_trace/interceptor/logging_hook.py +137 -0
- agent_trace/interceptor/requests_patch.py +184 -0
- agent_trace/interceptor/sse.py +176 -0
- agent_trace/interceptor/stdio_hook.py +245 -0
- agent_trace/interceptor/warnings_hook.py +132 -0
- agent_trace/interceptor/websocket_hook.py +323 -0
- agent_trace/plugins/__init__.py +35 -0
- agent_trace/plugins/base.py +111 -0
- agent_trace/py.typed +0 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Google GenAI integration — provider-specific span enrichment for
|
|
3
|
+
``google.genai.client.Client`` and ``langchain_google_genai.ChatGoogleGenerativeAI``.
|
|
4
|
+
|
|
5
|
+
The generic ``httpx``/``requests`` interceptor (see
|
|
6
|
+
``agent_trace.interceptor.httpx_hook``) already captures the raw wire bytes for
|
|
7
|
+
most Google GenAI traffic (the current ``google-genai`` SDK's API-key auth path
|
|
8
|
+
constructs ``httpx.Client`` subclasses that go through the patched
|
|
9
|
+
``httpx.Client.__init__``). What it does *not* do is surface the
|
|
10
|
+
provider-specific fields that actually explain a lot of Gemini bug reports —
|
|
11
|
+
``thinkingConfig``/``includeThoughts``/``thinkingBudget`` — as first-class span
|
|
12
|
+
attributes, or distinguish an LCEL-chain-routed call from a bare model
|
|
13
|
+
invocation. This module adds that layer on top, without duplicating the
|
|
14
|
+
wire-level capture the interceptor already does.
|
|
15
|
+
|
|
16
|
+
Two independent entry points are provided:
|
|
17
|
+
|
|
18
|
+
``GoogleGenAITracer``
|
|
19
|
+
A ``langchain_core`` ``BaseCallbackHandler`` for
|
|
20
|
+
``langchain_google_genai.ChatGoogleGenerativeAI`` (or any chain built on
|
|
21
|
+
top of it). Pass it in ``config={"callbacks": [...]}`` exactly like
|
|
22
|
+
``LangGraphTracer``.
|
|
23
|
+
|
|
24
|
+
``instrument_client`` / ``uninstrument_client``
|
|
25
|
+
Instance-level patches for the raw ``google.genai.client.Client`` SDK
|
|
26
|
+
(used directly by, e.g., crewAI's Gemini completion path) that wrap
|
|
27
|
+
``Client.models.generate_content`` / ``generate_content_stream`` to emit a
|
|
28
|
+
span per call with the same provider-specific attributes.
|
|
29
|
+
|
|
30
|
+
Usage (LangChain / LCEL)::
|
|
31
|
+
|
|
32
|
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
|
33
|
+
from agent_trace import tracer
|
|
34
|
+
from agent_trace.integrations.google_genai import GoogleGenAITracer
|
|
35
|
+
|
|
36
|
+
llm = ChatGoogleGenerativeAI(model="gemini-2.5-flash", thinking_budget=1024)
|
|
37
|
+
with tracer.start_trace("gemini_run", record=True) as trace:
|
|
38
|
+
cb = GoogleGenAITracer(tracer=tracer, trace=trace)
|
|
39
|
+
llm.invoke("hello", config={"callbacks": [cb]})
|
|
40
|
+
|
|
41
|
+
Usage (raw SDK)::
|
|
42
|
+
|
|
43
|
+
from google import genai
|
|
44
|
+
from agent_trace import tracer
|
|
45
|
+
from agent_trace.integrations.google_genai import instrument_client
|
|
46
|
+
|
|
47
|
+
client = genai.Client(api_key="...")
|
|
48
|
+
with tracer.start_trace("gemini_sdk_run", record=True) as trace:
|
|
49
|
+
instrument_client(client, tracer=tracer, trace=trace)
|
|
50
|
+
client.models.generate_content(model="gemini-2.5-flash", contents="hi")
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
from __future__ import annotations
|
|
54
|
+
|
|
55
|
+
import logging
|
|
56
|
+
import threading
|
|
57
|
+
import types
|
|
58
|
+
import uuid
|
|
59
|
+
from collections.abc import Iterator
|
|
60
|
+
from typing import TYPE_CHECKING, Any
|
|
61
|
+
from weakref import WeakKeyDictionary
|
|
62
|
+
|
|
63
|
+
from agent_trace.core.span import Span, SpanStatus
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING:
|
|
66
|
+
from collections.abc import Callable
|
|
67
|
+
|
|
68
|
+
from agent_trace import Trace, Tracer
|
|
69
|
+
|
|
70
|
+
__all__ = [
|
|
71
|
+
"GoogleGenAITracer",
|
|
72
|
+
"instrument_client",
|
|
73
|
+
"uninstrument_client",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
logger = logging.getLogger(__name__)
|
|
77
|
+
|
|
78
|
+
# Attribute value types accepted by Span.set_attribute.
|
|
79
|
+
_AttrValue = str | int | float | bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Shared helpers
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _get_field(obj: Any, key: str) -> Any:
|
|
88
|
+
"""Read *key* off *obj*, whether it's a dict or an attribute-bearing object.
|
|
89
|
+
|
|
90
|
+
``google-genai``'s ``types`` module and ``langchain_google_genai`` both use
|
|
91
|
+
pydantic models for config objects, but callers are free to pass plain
|
|
92
|
+
dicts (``GenerateContentConfigDict``) too — this normalises both shapes.
|
|
93
|
+
"""
|
|
94
|
+
if obj is None:
|
|
95
|
+
return None
|
|
96
|
+
if isinstance(obj, dict):
|
|
97
|
+
return obj.get(key)
|
|
98
|
+
return getattr(obj, key, None)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# langchain_google_genai.ChatGoogleGenerativeAI — callback handler
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
_LANGCHAIN_INSTALL_HINT = (
|
|
106
|
+
"GoogleGenAITracer requires langchain-core.\n"
|
|
107
|
+
"Install it with:\n\n"
|
|
108
|
+
" pip install langchain-core\n"
|
|
109
|
+
"\n"
|
|
110
|
+
"(langchain-google-genai is not strictly required — this tracer works on\n"
|
|
111
|
+
"any langchain_core callback stream and only enriches spans with Google\n"
|
|
112
|
+
"GenAI-specific fields when they're present.)\n"
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _require_langchain_core() -> Any:
|
|
117
|
+
"""Lazy import guard — raises a clear error if langchain_core is absent."""
|
|
118
|
+
try:
|
|
119
|
+
import langchain_core
|
|
120
|
+
|
|
121
|
+
return langchain_core
|
|
122
|
+
except ImportError as exc:
|
|
123
|
+
raise ImportError(_LANGCHAIN_INSTALL_HINT) from exc
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _base_callback_handler() -> Any:
|
|
127
|
+
"""Return langchain_core.callbacks.BaseCallbackHandler (lazy import)."""
|
|
128
|
+
_require_langchain_core()
|
|
129
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
130
|
+
|
|
131
|
+
return BaseCallbackHandler
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _extract_langchain_thinking_fields(
|
|
135
|
+
ser_kwargs: dict[str, Any],
|
|
136
|
+
) -> dict[str, _AttrValue]:
|
|
137
|
+
"""Pull the Gemini thinking-config fields out of a serialized model's kwargs.
|
|
138
|
+
|
|
139
|
+
``on_chat_model_start``'s ``serialized["kwargs"]`` mirrors
|
|
140
|
+
``ChatGoogleGenerativeAI``'s constructor kwargs verbatim (confirmed via
|
|
141
|
+
``ChatGoogleGenerativeAI(...).to_json()`` against langchain-google-genai
|
|
142
|
+
4.2.7) — ``thinking_budget``, ``thinking_level``, and ``include_thoughts``
|
|
143
|
+
are flat top-level fields; ``thinking_config`` (when set instead of the
|
|
144
|
+
flat fields) is a nested dict/``ThinkingConfig`` with the same three keys.
|
|
145
|
+
"""
|
|
146
|
+
attrs: dict[str, _AttrValue] = {}
|
|
147
|
+
|
|
148
|
+
include_thoughts = ser_kwargs.get("include_thoughts")
|
|
149
|
+
thinking_budget = ser_kwargs.get("thinking_budget")
|
|
150
|
+
thinking_level = ser_kwargs.get("thinking_level")
|
|
151
|
+
thinking_config = ser_kwargs.get("thinking_config")
|
|
152
|
+
|
|
153
|
+
if thinking_config:
|
|
154
|
+
include_thoughts = (
|
|
155
|
+
_get_field(thinking_config, "include_thoughts")
|
|
156
|
+
if include_thoughts is None
|
|
157
|
+
else include_thoughts
|
|
158
|
+
)
|
|
159
|
+
thinking_budget = (
|
|
160
|
+
_get_field(thinking_config, "thinking_budget")
|
|
161
|
+
if thinking_budget is None
|
|
162
|
+
else thinking_budget
|
|
163
|
+
)
|
|
164
|
+
thinking_level = (
|
|
165
|
+
_get_field(thinking_config, "thinking_level")
|
|
166
|
+
if thinking_level is None
|
|
167
|
+
else thinking_level
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if include_thoughts is not None:
|
|
171
|
+
attrs["google_genai.include_thoughts"] = bool(include_thoughts)
|
|
172
|
+
if thinking_budget is not None:
|
|
173
|
+
attrs["google_genai.thinking_budget"] = int(thinking_budget)
|
|
174
|
+
if thinking_level is not None:
|
|
175
|
+
attrs["google_genai.thinking_level"] = str(thinking_level)
|
|
176
|
+
|
|
177
|
+
return attrs
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _extract_langchain_usage(response: Any) -> dict[str, _AttrValue]:
|
|
181
|
+
"""Extract token usage from an ``LLMResult``, preferring per-message
|
|
182
|
+
``usage_metadata`` (where Gemini's thoughts-token count lives) and
|
|
183
|
+
falling back to the legacy ``llm_output['token_usage']`` shape used by
|
|
184
|
+
other providers.
|
|
185
|
+
"""
|
|
186
|
+
attrs: dict[str, _AttrValue] = {}
|
|
187
|
+
|
|
188
|
+
usage_meta: Any = None
|
|
189
|
+
generations = getattr(response, "generations", None) or []
|
|
190
|
+
if generations and generations[0]:
|
|
191
|
+
message = getattr(generations[0][0], "message", None)
|
|
192
|
+
if message is not None:
|
|
193
|
+
usage_meta = getattr(message, "usage_metadata", None)
|
|
194
|
+
|
|
195
|
+
if usage_meta:
|
|
196
|
+
input_tokens = _get_field(usage_meta, "input_tokens")
|
|
197
|
+
output_tokens = _get_field(usage_meta, "output_tokens")
|
|
198
|
+
total_tokens = _get_field(usage_meta, "total_tokens")
|
|
199
|
+
if input_tokens is not None:
|
|
200
|
+
attrs["llm.usage.prompt_tokens"] = int(input_tokens)
|
|
201
|
+
if output_tokens is not None:
|
|
202
|
+
attrs["llm.usage.completion_tokens"] = int(output_tokens)
|
|
203
|
+
if total_tokens is not None:
|
|
204
|
+
attrs["llm.usage.total_tokens"] = int(total_tokens)
|
|
205
|
+
|
|
206
|
+
output_details = _get_field(usage_meta, "output_token_details") or {}
|
|
207
|
+
reasoning_tokens = _get_field(output_details, "reasoning")
|
|
208
|
+
if reasoning_tokens:
|
|
209
|
+
attrs["google_genai.usage.thoughts_tokens"] = int(reasoning_tokens)
|
|
210
|
+
return attrs
|
|
211
|
+
|
|
212
|
+
# Fallback: legacy llm_output.token_usage shape (non-Gemini providers).
|
|
213
|
+
llm_output = getattr(response, "llm_output", {}) or {}
|
|
214
|
+
token_usage = llm_output.get("token_usage") or llm_output.get("usage") or {}
|
|
215
|
+
if token_usage:
|
|
216
|
+
attrs["llm.usage.prompt_tokens"] = int(token_usage.get("prompt_tokens", 0))
|
|
217
|
+
attrs["llm.usage.completion_tokens"] = int(
|
|
218
|
+
token_usage.get("completion_tokens", 0)
|
|
219
|
+
)
|
|
220
|
+
attrs["llm.usage.total_tokens"] = int(token_usage.get("total_tokens", 0))
|
|
221
|
+
return attrs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# Module-level singleton, built once — mirrors the LangGraphTracer pattern:
|
|
225
|
+
# BaseCallbackHandler is spliced in as a genuine base class at class-definition
|
|
226
|
+
# time (not via __bases__ mutation, which is unsafe under concurrency and
|
|
227
|
+
# forbidden in Python 3.14+).
|
|
228
|
+
_GoogleGenAITracerClass: type | None = None
|
|
229
|
+
_tracer_class_lock: threading.Lock = threading.Lock()
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _get_tracer_class() -> type:
|
|
233
|
+
"""Return (and lazily build) the concrete GoogleGenAITracer implementation."""
|
|
234
|
+
global _GoogleGenAITracerClass # noqa: PLW0603
|
|
235
|
+
if _GoogleGenAITracerClass is not None:
|
|
236
|
+
return _GoogleGenAITracerClass
|
|
237
|
+
|
|
238
|
+
with _tracer_class_lock:
|
|
239
|
+
if _GoogleGenAITracerClass is not None:
|
|
240
|
+
return _GoogleGenAITracerClass
|
|
241
|
+
|
|
242
|
+
base_cls: type = _base_callback_handler()
|
|
243
|
+
|
|
244
|
+
class _GoogleGenAITracerImpl(base_cls): # type: ignore[misc]
|
|
245
|
+
"""Concrete implementation — see GoogleGenAITracer for public docs."""
|
|
246
|
+
|
|
247
|
+
def __init__(self, tracer: Tracer, trace: Trace) -> None:
|
|
248
|
+
super().__init__()
|
|
249
|
+
self._tracer: Tracer = tracer
|
|
250
|
+
self._trace: Trace = trace
|
|
251
|
+
self._spans: dict[str, Span] = {}
|
|
252
|
+
self._lock: threading.Lock = threading.Lock()
|
|
253
|
+
|
|
254
|
+
# ------------------------------------------------------------
|
|
255
|
+
# Internal helpers
|
|
256
|
+
# ------------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
def _open_span(
|
|
259
|
+
self,
|
|
260
|
+
run_id: uuid.UUID | str,
|
|
261
|
+
name: str,
|
|
262
|
+
parent_run_id: uuid.UUID | str | None = None,
|
|
263
|
+
) -> Span:
|
|
264
|
+
run_key = str(run_id)
|
|
265
|
+
parent_span_id: str | None = None
|
|
266
|
+
if parent_run_id is not None:
|
|
267
|
+
with self._lock:
|
|
268
|
+
parent_span = self._spans.get(str(parent_run_id))
|
|
269
|
+
parent_span_id = (
|
|
270
|
+
parent_span.span_id if parent_span is not None else None
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
span = self._tracer.start_span(name, parent_id=parent_span_id)
|
|
274
|
+
with self._lock:
|
|
275
|
+
self._spans[run_key] = span
|
|
276
|
+
return span
|
|
277
|
+
|
|
278
|
+
def _close_span(
|
|
279
|
+
self,
|
|
280
|
+
run_id: uuid.UUID | str,
|
|
281
|
+
status: SpanStatus = SpanStatus.OK,
|
|
282
|
+
) -> Span | None:
|
|
283
|
+
run_key = str(run_id)
|
|
284
|
+
with self._lock:
|
|
285
|
+
span = self._spans.pop(run_key, None)
|
|
286
|
+
if span is not None and span.end_time is None:
|
|
287
|
+
span.end(status)
|
|
288
|
+
return span
|
|
289
|
+
|
|
290
|
+
def _close_span_with_exception(
|
|
291
|
+
self,
|
|
292
|
+
run_id: uuid.UUID | str,
|
|
293
|
+
error: BaseException,
|
|
294
|
+
) -> None:
|
|
295
|
+
run_key = str(run_id)
|
|
296
|
+
with self._lock:
|
|
297
|
+
span = self._spans.pop(run_key, None)
|
|
298
|
+
if span is not None:
|
|
299
|
+
span.record_exception(error)
|
|
300
|
+
if span.end_time is None:
|
|
301
|
+
span.end(SpanStatus.ERROR)
|
|
302
|
+
|
|
303
|
+
# ------------------------------------------------------------
|
|
304
|
+
# Chain callbacks (generic — lets this tracer run standalone,
|
|
305
|
+
# without LangGraphTracer, on a plain LCEL chain)
|
|
306
|
+
# ------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
def on_chain_start(
|
|
309
|
+
self,
|
|
310
|
+
serialized: dict[str, Any],
|
|
311
|
+
inputs: dict[str, Any],
|
|
312
|
+
*,
|
|
313
|
+
run_id: uuid.UUID,
|
|
314
|
+
parent_run_id: uuid.UUID | None = None,
|
|
315
|
+
tags: list[str] | None = None,
|
|
316
|
+
metadata: dict[str, Any] | None = None,
|
|
317
|
+
**kwargs: Any,
|
|
318
|
+
) -> None:
|
|
319
|
+
ser = serialized or {}
|
|
320
|
+
chain_name: str = (
|
|
321
|
+
kwargs.get("name")
|
|
322
|
+
or ser.get("name")
|
|
323
|
+
or (ser.get("id") or [None])[-1]
|
|
324
|
+
or "chain"
|
|
325
|
+
)
|
|
326
|
+
span = self._open_span(run_id, f"chain:{chain_name}", parent_run_id)
|
|
327
|
+
span.set_attribute("chain.name", chain_name)
|
|
328
|
+
|
|
329
|
+
def on_chain_end(
|
|
330
|
+
self,
|
|
331
|
+
outputs: dict[str, Any],
|
|
332
|
+
*,
|
|
333
|
+
run_id: uuid.UUID,
|
|
334
|
+
parent_run_id: uuid.UUID | None = None,
|
|
335
|
+
tags: list[str] | None = None,
|
|
336
|
+
**kwargs: Any,
|
|
337
|
+
) -> None:
|
|
338
|
+
self._close_span(run_id, SpanStatus.OK)
|
|
339
|
+
|
|
340
|
+
def on_chain_error(
|
|
341
|
+
self,
|
|
342
|
+
error: BaseException,
|
|
343
|
+
*,
|
|
344
|
+
run_id: uuid.UUID,
|
|
345
|
+
parent_run_id: uuid.UUID | None = None,
|
|
346
|
+
tags: list[str] | None = None,
|
|
347
|
+
**kwargs: Any,
|
|
348
|
+
) -> None:
|
|
349
|
+
self._close_span_with_exception(run_id, error)
|
|
350
|
+
|
|
351
|
+
# ------------------------------------------------------------
|
|
352
|
+
# LLM callbacks
|
|
353
|
+
# ------------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
def on_llm_start(
|
|
356
|
+
self,
|
|
357
|
+
serialized: dict[str, Any],
|
|
358
|
+
prompts: list[str],
|
|
359
|
+
*,
|
|
360
|
+
run_id: uuid.UUID,
|
|
361
|
+
parent_run_id: uuid.UUID | None = None,
|
|
362
|
+
tags: list[str] | None = None,
|
|
363
|
+
metadata: dict[str, Any] | None = None,
|
|
364
|
+
**kwargs: Any,
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Start a span for a legacy (non-chat) LLM call."""
|
|
367
|
+
ser = serialized or {}
|
|
368
|
+
ser_kwargs = ser.get("kwargs") or {}
|
|
369
|
+
model_name: str = (
|
|
370
|
+
ser_kwargs.get("model")
|
|
371
|
+
or kwargs.get("name")
|
|
372
|
+
or ser_kwargs.get("model_name")
|
|
373
|
+
or ser.get("name")
|
|
374
|
+
or "llm"
|
|
375
|
+
)
|
|
376
|
+
span = self._open_span(run_id, f"llm:{model_name}", parent_run_id)
|
|
377
|
+
span.set_attribute("llm.model", model_name)
|
|
378
|
+
span.set_attribute("llm.prompt_count", len(prompts))
|
|
379
|
+
span.set_attribute(
|
|
380
|
+
"google_genai.invocation_context",
|
|
381
|
+
"lcel_chain" if parent_run_id is not None else "direct_invocation",
|
|
382
|
+
)
|
|
383
|
+
for key, value in _extract_langchain_thinking_fields(
|
|
384
|
+
ser_kwargs
|
|
385
|
+
).items():
|
|
386
|
+
span.set_attribute(key, value)
|
|
387
|
+
|
|
388
|
+
def on_chat_model_start(
|
|
389
|
+
self,
|
|
390
|
+
serialized: dict[str, Any],
|
|
391
|
+
messages: list[list[Any]],
|
|
392
|
+
*,
|
|
393
|
+
run_id: uuid.UUID,
|
|
394
|
+
parent_run_id: uuid.UUID | None = None,
|
|
395
|
+
tags: list[str] | None = None,
|
|
396
|
+
metadata: dict[str, Any] | None = None,
|
|
397
|
+
**kwargs: Any,
|
|
398
|
+
) -> None:
|
|
399
|
+
"""Start a span for a ChatGoogleGenerativeAI call.
|
|
400
|
+
|
|
401
|
+
Captures ``thinkingConfig``/``includeThoughts``/``thinkingBudget``
|
|
402
|
+
onto the span, and records whether this call was routed through
|
|
403
|
+
an LCEL chain (``parent_run_id is not None``) or invoked bare
|
|
404
|
+
(``parent_run_id is None`` — confirmed via a direct probe against
|
|
405
|
+
``langchain-core`` 1.4.9's callback manager: a bare
|
|
406
|
+
``llm.invoke(...)`` fires ``on_chat_model_start`` with
|
|
407
|
+
``parent_run_id=None``, while ``(prompt | llm | parser).invoke(...)``
|
|
408
|
+
fires it with ``parent_run_id`` set to the chain's run id).
|
|
409
|
+
"""
|
|
410
|
+
ser = serialized or {}
|
|
411
|
+
ser_kwargs = ser.get("kwargs") or {}
|
|
412
|
+
model_name: str = (
|
|
413
|
+
ser_kwargs.get("model")
|
|
414
|
+
or kwargs.get("name")
|
|
415
|
+
or ser_kwargs.get("model_name")
|
|
416
|
+
or ser.get("name")
|
|
417
|
+
or "unknown"
|
|
418
|
+
)
|
|
419
|
+
span = self._open_span(run_id, f"llm:{model_name}", parent_run_id)
|
|
420
|
+
span.set_attribute("llm.model", model_name)
|
|
421
|
+
span.set_attribute(
|
|
422
|
+
"google_genai.invocation_context",
|
|
423
|
+
"lcel_chain" if parent_run_id is not None else "direct_invocation",
|
|
424
|
+
)
|
|
425
|
+
for key, value in _extract_langchain_thinking_fields(
|
|
426
|
+
ser_kwargs
|
|
427
|
+
).items():
|
|
428
|
+
span.set_attribute(key, value)
|
|
429
|
+
|
|
430
|
+
def on_llm_end(
|
|
431
|
+
self,
|
|
432
|
+
response: Any,
|
|
433
|
+
*,
|
|
434
|
+
run_id: uuid.UUID,
|
|
435
|
+
parent_run_id: uuid.UUID | None = None,
|
|
436
|
+
tags: list[str] | None = None,
|
|
437
|
+
**kwargs: Any,
|
|
438
|
+
) -> None:
|
|
439
|
+
"""End the LLM span, attaching token usage (incl. thoughts tokens)."""
|
|
440
|
+
run_key = str(run_id)
|
|
441
|
+
with self._lock:
|
|
442
|
+
span = self._spans.get(run_key)
|
|
443
|
+
if span is not None:
|
|
444
|
+
try:
|
|
445
|
+
for key, value in _extract_langchain_usage(response).items():
|
|
446
|
+
span.set_attribute(key, value)
|
|
447
|
+
except Exception:
|
|
448
|
+
logger.debug(
|
|
449
|
+
"agent-trace: failed to record token usage for run %r",
|
|
450
|
+
str(run_id),
|
|
451
|
+
exc_info=True,
|
|
452
|
+
)
|
|
453
|
+
self._close_span(run_id, SpanStatus.OK)
|
|
454
|
+
|
|
455
|
+
def on_llm_error(
|
|
456
|
+
self,
|
|
457
|
+
error: BaseException,
|
|
458
|
+
*,
|
|
459
|
+
run_id: uuid.UUID,
|
|
460
|
+
parent_run_id: uuid.UUID | None = None,
|
|
461
|
+
tags: list[str] | None = None,
|
|
462
|
+
**kwargs: Any,
|
|
463
|
+
) -> None:
|
|
464
|
+
self._close_span_with_exception(run_id, error)
|
|
465
|
+
|
|
466
|
+
# ------------------------------------------------------------
|
|
467
|
+
# Tool callbacks (generic passthrough — matches LangGraphTracer)
|
|
468
|
+
# ------------------------------------------------------------
|
|
469
|
+
|
|
470
|
+
def on_tool_start(
|
|
471
|
+
self,
|
|
472
|
+
serialized: dict[str, Any],
|
|
473
|
+
input_str: str,
|
|
474
|
+
*,
|
|
475
|
+
run_id: uuid.UUID,
|
|
476
|
+
parent_run_id: uuid.UUID | None = None,
|
|
477
|
+
tags: list[str] | None = None,
|
|
478
|
+
metadata: dict[str, Any] | None = None,
|
|
479
|
+
**kwargs: Any,
|
|
480
|
+
) -> None:
|
|
481
|
+
ser = serialized or {}
|
|
482
|
+
tool_name: str = kwargs.get("name") or ser.get("name") or "tool"
|
|
483
|
+
span = self._open_span(run_id, f"tool:{tool_name}", parent_run_id)
|
|
484
|
+
span.set_attribute("tool.name", tool_name)
|
|
485
|
+
|
|
486
|
+
def on_tool_end(
|
|
487
|
+
self,
|
|
488
|
+
output: str,
|
|
489
|
+
*,
|
|
490
|
+
run_id: uuid.UUID,
|
|
491
|
+
parent_run_id: uuid.UUID | None = None,
|
|
492
|
+
tags: list[str] | None = None,
|
|
493
|
+
**kwargs: Any,
|
|
494
|
+
) -> None:
|
|
495
|
+
self._close_span(run_id, SpanStatus.OK)
|
|
496
|
+
|
|
497
|
+
def on_tool_error(
|
|
498
|
+
self,
|
|
499
|
+
error: BaseException,
|
|
500
|
+
*,
|
|
501
|
+
run_id: uuid.UUID,
|
|
502
|
+
parent_run_id: uuid.UUID | None = None,
|
|
503
|
+
tags: list[str] | None = None,
|
|
504
|
+
**kwargs: Any,
|
|
505
|
+
) -> None:
|
|
506
|
+
self._close_span_with_exception(run_id, error)
|
|
507
|
+
|
|
508
|
+
_GoogleGenAITracerClass = _GoogleGenAITracerImpl
|
|
509
|
+
return _GoogleGenAITracerClass
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
class GoogleGenAITracer:
|
|
513
|
+
"""Langchain callback handler that emits Google-GenAI-enriched spans.
|
|
514
|
+
|
|
515
|
+
Implements the ``BaseCallbackHandler`` interface from ``langchain_core``
|
|
516
|
+
so it can be passed directly in the ``config["callbacks"]`` list of any
|
|
517
|
+
LangChain-based call — bare ``ChatGoogleGenerativeAI.invoke()``, an LCEL
|
|
518
|
+
chain, or a LangGraph node. Unlike ``LangGraphTracer``, this tracer also
|
|
519
|
+
captures Gemini-specific request fields (``thinking_config`` /
|
|
520
|
+
``include_thoughts`` / ``thinking_budget``) and marks each LLM span with
|
|
521
|
+
``google_genai.invocation_context`` so a "why did the chain path behave
|
|
522
|
+
differently from the direct-model path" question (the shape of issue
|
|
523
|
+
#31767) can be answered from span attributes alone.
|
|
524
|
+
|
|
525
|
+
Parameters
|
|
526
|
+
----------
|
|
527
|
+
tracer:
|
|
528
|
+
The active :class:`~agent_trace.Tracer` instance.
|
|
529
|
+
trace:
|
|
530
|
+
The :class:`~agent_trace.Trace` that spans will be registered on.
|
|
531
|
+
"""
|
|
532
|
+
|
|
533
|
+
def __new__(cls, tracer: Tracer, trace: Trace) -> GoogleGenAITracer:
|
|
534
|
+
impl_cls = _get_tracer_class()
|
|
535
|
+
return impl_cls(tracer, trace) # type: ignore[no-any-return]
|
|
536
|
+
|
|
537
|
+
def __init__(self, tracer: Tracer, trace: Trace) -> None:
|
|
538
|
+
# Reached only if someone subclasses GoogleGenAITracer directly;
|
|
539
|
+
# normal construction goes through the impl class __init__ (see
|
|
540
|
+
# __new__ and the LangGraphTracer docstring for why).
|
|
541
|
+
pass # pragma: no cover
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# google.genai.client.Client — raw SDK instrumentation
|
|
546
|
+
# ---------------------------------------------------------------------------
|
|
547
|
+
|
|
548
|
+
_GOOGLE_GENAI_INSTALL_HINT = (
|
|
549
|
+
"instrument_client requires the google-genai package.\n"
|
|
550
|
+
"Install it with:\n\n"
|
|
551
|
+
" pip install google-genai\n"
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def _require_google_genai() -> Any:
|
|
556
|
+
"""Lazy import guard — raises a clear error if google-genai is absent."""
|
|
557
|
+
try:
|
|
558
|
+
import google.genai
|
|
559
|
+
|
|
560
|
+
return google.genai
|
|
561
|
+
except ImportError as exc:
|
|
562
|
+
raise ImportError(_GOOGLE_GENAI_INSTALL_HINT) from exc
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _extract_sdk_thinking_fields(config: Any) -> dict[str, _AttrValue]:
|
|
566
|
+
"""Extract thinking-config fields from a ``GenerateContentConfig``.
|
|
567
|
+
|
|
568
|
+
Handles both the pydantic ``GenerateContentConfig``/``ThinkingConfig``
|
|
569
|
+
objects and their ``TypedDict`` equivalents (``GenerateContentConfigDict``
|
|
570
|
+
accepts a plain dict for ``config``, confirmed against google-genai 2.10.0).
|
|
571
|
+
"""
|
|
572
|
+
attrs: dict[str, _AttrValue] = {}
|
|
573
|
+
thinking = _get_field(config, "thinking_config")
|
|
574
|
+
if thinking is None:
|
|
575
|
+
return attrs
|
|
576
|
+
|
|
577
|
+
include_thoughts = _get_field(thinking, "include_thoughts")
|
|
578
|
+
thinking_budget = _get_field(thinking, "thinking_budget")
|
|
579
|
+
thinking_level = _get_field(thinking, "thinking_level")
|
|
580
|
+
|
|
581
|
+
if include_thoughts is not None:
|
|
582
|
+
attrs["google_genai.include_thoughts"] = bool(include_thoughts)
|
|
583
|
+
if thinking_budget is not None:
|
|
584
|
+
attrs["google_genai.thinking_budget"] = int(thinking_budget)
|
|
585
|
+
if thinking_level is not None:
|
|
586
|
+
attrs["google_genai.thinking_level"] = str(thinking_level)
|
|
587
|
+
return attrs
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _extract_sdk_usage_fields(response: Any) -> dict[str, _AttrValue]:
|
|
591
|
+
"""Extract token usage from a ``GenerateContentResponse.usage_metadata``."""
|
|
592
|
+
attrs: dict[str, _AttrValue] = {}
|
|
593
|
+
usage = getattr(response, "usage_metadata", None)
|
|
594
|
+
if usage is None:
|
|
595
|
+
return attrs
|
|
596
|
+
|
|
597
|
+
prompt_tokens = _get_field(usage, "prompt_token_count")
|
|
598
|
+
candidates_tokens = _get_field(usage, "candidates_token_count")
|
|
599
|
+
total_tokens = _get_field(usage, "total_token_count")
|
|
600
|
+
thoughts_tokens = _get_field(usage, "thoughts_token_count")
|
|
601
|
+
|
|
602
|
+
if prompt_tokens is not None:
|
|
603
|
+
attrs["llm.usage.prompt_tokens"] = int(prompt_tokens)
|
|
604
|
+
if candidates_tokens is not None:
|
|
605
|
+
attrs["llm.usage.completion_tokens"] = int(candidates_tokens)
|
|
606
|
+
if total_tokens is not None:
|
|
607
|
+
attrs["llm.usage.total_tokens"] = int(total_tokens)
|
|
608
|
+
if thoughts_tokens is not None:
|
|
609
|
+
attrs["google_genai.usage.thoughts_tokens"] = int(thoughts_tokens)
|
|
610
|
+
return attrs
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _make_generate_content_wrapper(
|
|
614
|
+
original: Callable[..., Any], tracer: Tracer
|
|
615
|
+
) -> Callable[..., Any]:
|
|
616
|
+
def _generate_content(
|
|
617
|
+
self: Any, *, model: str, contents: Any, config: Any = None, **kwargs: Any
|
|
618
|
+
) -> Any:
|
|
619
|
+
span = tracer.start_span(f"google_genai.generate_content:{model}")
|
|
620
|
+
span.set_attribute("llm.model", model)
|
|
621
|
+
for key, value in _extract_sdk_thinking_fields(config).items():
|
|
622
|
+
span.set_attribute(key, value)
|
|
623
|
+
try:
|
|
624
|
+
response = original(model=model, contents=contents, config=config, **kwargs)
|
|
625
|
+
except Exception as exc:
|
|
626
|
+
span.record_exception(exc)
|
|
627
|
+
if span.end_time is None:
|
|
628
|
+
span.end(SpanStatus.ERROR)
|
|
629
|
+
raise
|
|
630
|
+
for key, value in _extract_sdk_usage_fields(response).items():
|
|
631
|
+
span.set_attribute(key, value)
|
|
632
|
+
if span.end_time is None:
|
|
633
|
+
span.end(SpanStatus.OK)
|
|
634
|
+
return response
|
|
635
|
+
|
|
636
|
+
return _generate_content
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def _make_generate_content_stream_wrapper(
|
|
640
|
+
original: Callable[..., Any], tracer: Tracer
|
|
641
|
+
) -> Callable[..., Any]:
|
|
642
|
+
def _generate_content_stream(
|
|
643
|
+
self: Any, *, model: str, contents: Any, config: Any = None, **kwargs: Any
|
|
644
|
+
) -> Iterator[Any]:
|
|
645
|
+
span = tracer.start_span(f"google_genai.generate_content_stream:{model}")
|
|
646
|
+
span.set_attribute("llm.model", model)
|
|
647
|
+
for key, value in _extract_sdk_thinking_fields(config).items():
|
|
648
|
+
span.set_attribute(key, value)
|
|
649
|
+
|
|
650
|
+
last_chunk: Any = None
|
|
651
|
+
try:
|
|
652
|
+
stream = original(model=model, contents=contents, config=config, **kwargs)
|
|
653
|
+
for chunk in stream:
|
|
654
|
+
last_chunk = chunk
|
|
655
|
+
yield chunk
|
|
656
|
+
except Exception as exc:
|
|
657
|
+
span.record_exception(exc)
|
|
658
|
+
if span.end_time is None:
|
|
659
|
+
span.end(SpanStatus.ERROR)
|
|
660
|
+
raise
|
|
661
|
+
else:
|
|
662
|
+
# Gemini's streaming API returns cumulative usage_metadata with
|
|
663
|
+
# each chunk, so the final chunk carries the full-request totals.
|
|
664
|
+
if last_chunk is not None:
|
|
665
|
+
for key, value in _extract_sdk_usage_fields(last_chunk).items():
|
|
666
|
+
span.set_attribute(key, value)
|
|
667
|
+
if span.end_time is None:
|
|
668
|
+
span.end(SpanStatus.OK)
|
|
669
|
+
|
|
670
|
+
return _generate_content_stream
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# Registry of instrumented Models instances -> their original bound methods,
|
|
674
|
+
# so uninstrument_client() can restore them. Keyed by the Models instance
|
|
675
|
+
# (weakly) rather than the Client, since Client.models is the stable object
|
|
676
|
+
# whose methods we actually patch (Client() itself is a thin facade over it —
|
|
677
|
+
# confirmed against google-genai 2.10.0's Client.__init__, which builds
|
|
678
|
+
# self._models once and exposes it via a `models` property).
|
|
679
|
+
_instrumented_methods: WeakKeyDictionary[Any, dict[str, Any]] = WeakKeyDictionary()
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def instrument_client(client: Any, *, tracer: Tracer, trace: Trace) -> None:
|
|
683
|
+
"""Patch *client*'s ``models.generate_content``/``generate_content_stream``
|
|
684
|
+
to emit a span per call, enriched with thinking-config and usage fields.
|
|
685
|
+
|
|
686
|
+
Idempotent: calling this twice on the same client is a no-op the second
|
|
687
|
+
time. Pair with :func:`uninstrument_client` to restore the originals.
|
|
688
|
+
|
|
689
|
+
Parameters
|
|
690
|
+
----------
|
|
691
|
+
client:
|
|
692
|
+
A ``google.genai.client.Client`` (or ``google.genai.Client``) instance.
|
|
693
|
+
tracer:
|
|
694
|
+
The active :class:`~agent_trace.Tracer` instance.
|
|
695
|
+
trace:
|
|
696
|
+
The :class:`~agent_trace.Trace` that spans will be registered on
|
|
697
|
+
(must already be the active trace — spans are created via
|
|
698
|
+
``tracer.start_span``, which attaches to whatever trace is active).
|
|
699
|
+
"""
|
|
700
|
+
_require_google_genai()
|
|
701
|
+
|
|
702
|
+
models_obj = client.models
|
|
703
|
+
if models_obj in _instrumented_methods:
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
originals = {
|
|
707
|
+
"generate_content": models_obj.generate_content,
|
|
708
|
+
"generate_content_stream": models_obj.generate_content_stream,
|
|
709
|
+
}
|
|
710
|
+
_instrumented_methods[models_obj] = originals
|
|
711
|
+
|
|
712
|
+
models_obj.generate_content = types.MethodType(
|
|
713
|
+
_make_generate_content_wrapper(originals["generate_content"], tracer),
|
|
714
|
+
models_obj,
|
|
715
|
+
)
|
|
716
|
+
models_obj.generate_content_stream = types.MethodType(
|
|
717
|
+
_make_generate_content_stream_wrapper(
|
|
718
|
+
originals["generate_content_stream"], tracer
|
|
719
|
+
),
|
|
720
|
+
models_obj,
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def uninstrument_client(client: Any) -> None:
|
|
725
|
+
"""Restore the original (unpatched) methods on *client*'s ``models``."""
|
|
726
|
+
models_obj = client.models
|
|
727
|
+
originals = _instrumented_methods.pop(models_obj, None)
|
|
728
|
+
if originals is None:
|
|
729
|
+
return
|
|
730
|
+
models_obj.generate_content = originals["generate_content"]
|
|
731
|
+
models_obj.generate_content_stream = originals["generate_content_stream"]
|