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.
Files changed (51) hide show
  1. agent_observability_trace_cli-0.1.2.dist-info/METADATA +336 -0
  2. agent_observability_trace_cli-0.1.2.dist-info/RECORD +51 -0
  3. agent_observability_trace_cli-0.1.2.dist-info/WHEEL +4 -0
  4. agent_observability_trace_cli-0.1.2.dist-info/entry_points.txt +2 -0
  5. agent_observability_trace_cli-0.1.2.dist-info/licenses/LICENSE +198 -0
  6. agent_trace/__init__.py +1182 -0
  7. agent_trace/_cli.py +1377 -0
  8. agent_trace/_inspect.py +2020 -0
  9. agent_trace/_replay/__init__.py +1 -0
  10. agent_trace/_replay/engine.py +268 -0
  11. agent_trace/_replay/fixture.py +868 -0
  12. agent_trace/core/__init__.py +1 -0
  13. agent_trace/core/clock.py +91 -0
  14. agent_trace/core/exceptions.py +51 -0
  15. agent_trace/core/span.py +271 -0
  16. agent_trace/core/trace.py +92 -0
  17. agent_trace/exporters/__init__.py +1 -0
  18. agent_trace/exporters/file.py +80 -0
  19. agent_trace/exporters/otlp.py +199 -0
  20. agent_trace/exporters/remote_fixture.py +307 -0
  21. agent_trace/exporters/stdout.py +258 -0
  22. agent_trace/integrations/__init__.py +69 -0
  23. agent_trace/integrations/agno.py +489 -0
  24. agent_trace/integrations/autogen.py +581 -0
  25. agent_trace/integrations/crewai.py +486 -0
  26. agent_trace/integrations/google_genai.py +731 -0
  27. agent_trace/integrations/haystack.py +254 -0
  28. agent_trace/integrations/langchain_core.py +361 -0
  29. agent_trace/integrations/langgraph.py +2093 -0
  30. agent_trace/integrations/langgraph_checkpoint.py +763 -0
  31. agent_trace/integrations/langgraph_state_diff.py +345 -0
  32. agent_trace/integrations/langgraph_stream_debug.py +202 -0
  33. agent_trace/integrations/llama_index.py +472 -0
  34. agent_trace/integrations/mcp.py +281 -0
  35. agent_trace/integrations/openai_agents.py +811 -0
  36. agent_trace/integrations/pydantic_ai.py +504 -0
  37. agent_trace/integrations/streaming.py +218 -0
  38. agent_trace/interceptor/__init__.py +64 -0
  39. agent_trace/interceptor/aiohttp_hook.py +152 -0
  40. agent_trace/interceptor/botocore_hook.py +223 -0
  41. agent_trace/interceptor/grpc_hook.py +651 -0
  42. agent_trace/interceptor/httpx_hook.py +866 -0
  43. agent_trace/interceptor/logging_hook.py +137 -0
  44. agent_trace/interceptor/requests_patch.py +184 -0
  45. agent_trace/interceptor/sse.py +176 -0
  46. agent_trace/interceptor/stdio_hook.py +245 -0
  47. agent_trace/interceptor/warnings_hook.py +132 -0
  48. agent_trace/interceptor/websocket_hook.py +323 -0
  49. agent_trace/plugins/__init__.py +35 -0
  50. agent_trace/plugins/base.py +111 -0
  51. 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"]