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,763 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Checkpointer + node-cache instrumentation for the LangGraph integration.
|
|
3
|
+
|
|
4
|
+
Background
|
|
5
|
+
----------
|
|
6
|
+
|
|
7
|
+
``LangGraphTracer`` (``agent_trace.integrations.langgraph``) only implements
|
|
8
|
+
the standard ``BaseCallbackHandler`` chain/LLM/tool lifecycle
|
|
9
|
+
(``on_chain_*``/``on_llm_*``/``on_tool_*``). Checkpointer persistence
|
|
10
|
+
(``BaseCheckpointSaver.put``/``put_writes``, and the serializer boundary
|
|
11
|
+
those go through) and per-node ``CachePolicy`` hit/miss decisions
|
|
12
|
+
(``BaseCache.get``/``set``) are structurally different interfaces with no
|
|
13
|
+
callback hook at all — confirmed via a repo-wide grep (zero hits for
|
|
14
|
+
``checkpoint|serde|dumps_typed|loads_typed|CachePolicy`` anywhere in
|
|
15
|
+
``src/agent_trace/`` before this module). Bugs in state persistence (loss on
|
|
16
|
+
cancellation, storage bloat, a write silently attributed to the wrong node,
|
|
17
|
+
a non-deterministic cache key) are therefore invisible to agent-trace today
|
|
18
|
+
regardless of recording mode.
|
|
19
|
+
|
|
20
|
+
This module wraps three *public, stable* LangGraph interfaces — confirmed by
|
|
21
|
+
direct inspection of the installed ``langgraph`` package (see docstrings on
|
|
22
|
+
each class/function below), not private/internal modules — so it stays
|
|
23
|
+
correct across LangGraph versions the same way the rest of this codebase's
|
|
24
|
+
checkpointer-adjacent code does:
|
|
25
|
+
|
|
26
|
+
- ``BaseCheckpointSaver`` (``langgraph.checkpoint.base``): the ABC every
|
|
27
|
+
checkpointer implementation (``InMemorySaver``, ``SqliteSaver``,
|
|
28
|
+
``PostgresSaver``, ...) subclasses. ``TracingCheckpointSaver`` wraps one
|
|
29
|
+
instance of it, recording a span per ``put``/``aput``/``put_writes``/
|
|
30
|
+
``aput_writes`` call (timestamp via span start/end, payload size,
|
|
31
|
+
whether the call actually completed), and delegates every other method
|
|
32
|
+
unchanged.
|
|
33
|
+
- ``SerializerProtocol`` (``langgraph.checkpoint.serde.base``): the
|
|
34
|
+
``dumps_typed``/``loads_typed`` boundary every checkpointer calls to
|
|
35
|
+
turn a ``Checkpoint``/write value into bytes and back.
|
|
36
|
+
``TracingCheckpointSaver`` installs a ``TracingSerde`` wrapper directly
|
|
37
|
+
onto the *wrapped* checkpointer's own ``.serde`` attribute (not just its
|
|
38
|
+
own), so serde calls made internally by the checkpointer's own
|
|
39
|
+
``put``/``get`` implementations are captured too, not just calls this
|
|
40
|
+
module happens to make directly.
|
|
41
|
+
- ``BaseCache`` (``langgraph.cache.base``): the ABC every node-cache
|
|
42
|
+
backend (``InMemoryCache``, ``RedisCache``, ...) subclasses.
|
|
43
|
+
``TracingCache`` wraps one instance, recording hit/miss per
|
|
44
|
+
``get``/``aget`` call and key/count per ``set``/``aset`` call.
|
|
45
|
+
- ``CachePolicy.key_func`` (``langgraph.types``): the function LangGraph
|
|
46
|
+
actually calls to hash a node's input into the cache key. ``BaseCache``
|
|
47
|
+
itself only ever sees the *already-computed* key, never the state
|
|
48
|
+
object that produced it — ``wrap_cache_policy`` traces the key_func
|
|
49
|
+
itself so the actual hashed input is captured, not just the resulting
|
|
50
|
+
key.
|
|
51
|
+
|
|
52
|
+
Plus two wrapper functions, ``traced_update_state``/``traced_aupdate_state``,
|
|
53
|
+
around the public ``CompiledStateGraph.update_state``/``.aupdate_state``
|
|
54
|
+
methods: they record which node an external state write was attributed to
|
|
55
|
+
(the ``as_node`` argument, when the caller supplied one explicitly) and the
|
|
56
|
+
pregel scheduler's resulting ``next`` task list immediately afterward (via
|
|
57
|
+
the public ``.get_state()``/``.aget_state()`` call), flagging
|
|
58
|
+
``checkpoint.zero_tasks_scheduled=True`` when that list comes back empty —
|
|
59
|
+
the exact silent-no-op-resume shape behind issue #4217.
|
|
60
|
+
|
|
61
|
+
All instrumentation here is strictly additive and best-effort: any failure
|
|
62
|
+
recording span data is swallowed (logged at DEBUG) so a bug in this module
|
|
63
|
+
can never change the behavior — or break the exception propagation — of the
|
|
64
|
+
real checkpointer/cache/update_state call it wraps.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import asyncio
|
|
70
|
+
import logging
|
|
71
|
+
import time
|
|
72
|
+
from typing import TYPE_CHECKING, Any
|
|
73
|
+
|
|
74
|
+
from agent_trace.core.span import Span, SpanStatus
|
|
75
|
+
from agent_trace.integrations.langgraph import _stringify, _to_attr_string
|
|
76
|
+
|
|
77
|
+
if TYPE_CHECKING:
|
|
78
|
+
from agent_trace import Tracer
|
|
79
|
+
|
|
80
|
+
__all__ = [
|
|
81
|
+
"TracingCache",
|
|
82
|
+
"TracingCheckpointSaver",
|
|
83
|
+
"TracingSerde",
|
|
84
|
+
"traced_aupdate_state",
|
|
85
|
+
"traced_update_state",
|
|
86
|
+
"wrap_cache_policy",
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
_INSTALL_HINT = (
|
|
92
|
+
"Checkpointer/cache tracing requires langgraph.\n"
|
|
93
|
+
"Install it with:\n\n"
|
|
94
|
+
" pip install langgraph\n"
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _require_checkpoint_base() -> Any:
|
|
99
|
+
"""Lazy import of langgraph.checkpoint.base — raises a clear error if
|
|
100
|
+
langgraph is absent, mirroring _require_langchain_core() in langgraph.py."""
|
|
101
|
+
try:
|
|
102
|
+
from langgraph.checkpoint import base
|
|
103
|
+
|
|
104
|
+
return base
|
|
105
|
+
except ImportError as exc:
|
|
106
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _require_cache_base() -> Any:
|
|
110
|
+
"""Lazy import of langgraph.cache.base."""
|
|
111
|
+
try:
|
|
112
|
+
from langgraph.cache import base
|
|
113
|
+
|
|
114
|
+
return base
|
|
115
|
+
except ImportError as exc:
|
|
116
|
+
raise ImportError(_INSTALL_HINT) from exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _extract_thread_id(config: Any) -> str | None:
|
|
120
|
+
"""Best-effort thread_id extraction from a LangGraph RunnableConfig."""
|
|
121
|
+
try:
|
|
122
|
+
configurable = (config or {}).get("configurable") or {}
|
|
123
|
+
thread_id = configurable.get("thread_id")
|
|
124
|
+
return str(thread_id) if thread_id is not None else None
|
|
125
|
+
except Exception:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _estimate_size(value: Any) -> int | None:
|
|
130
|
+
"""Best-effort byte-size estimate of *value* — a UTF-8 length of its
|
|
131
|
+
bounded JSON serialization, not a precise wire size. Good enough to spot
|
|
132
|
+
order-of-magnitude storage bloat (the failure class behind issue #7714)
|
|
133
|
+
without depending on any particular checkpointer's own encoding."""
|
|
134
|
+
try:
|
|
135
|
+
return len(_to_attr_string(value, max_len=2_000_000).encode("utf-8"))
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
# TracingSerde — checkpointer serde-boundary capture
|
|
142
|
+
# ---------------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TracingSerde:
|
|
146
|
+
"""Wraps a ``SerializerProtocol`` implementation, recording per-call
|
|
147
|
+
payload byte size and duration as a span per ``dumps_typed``/
|
|
148
|
+
``loads_typed`` call.
|
|
149
|
+
|
|
150
|
+
Confirmed against the installed langgraph's
|
|
151
|
+
``langgraph.checkpoint.serde.base.SerializerProtocol``: the only two
|
|
152
|
+
methods every real serde implementation must provide are
|
|
153
|
+
``dumps_typed(obj) -> (type_name, bytes)`` and
|
|
154
|
+
``loads_typed((type_name, bytes)) -> obj``. ``dumps``/``loads`` (the
|
|
155
|
+
legacy untyped shape) are also forwarded for serde implementations that
|
|
156
|
+
still expose them, but are not the primary capture point since
|
|
157
|
+
``BaseCheckpointSaver`` upgrades any untyped serde to a typed one via
|
|
158
|
+
``maybe_add_typed_methods`` before ever calling it.
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
def __init__(self, inner: Any, tracer: Tracer) -> None:
|
|
162
|
+
self._inner = inner
|
|
163
|
+
self._tracer = tracer
|
|
164
|
+
|
|
165
|
+
def dumps_typed(self, obj: Any) -> tuple[str, bytes]:
|
|
166
|
+
t0 = time.monotonic()
|
|
167
|
+
type_name, data = self._inner.dumps_typed(obj)
|
|
168
|
+
self._record("dumps_typed", type_name, len(data), time.monotonic() - t0)
|
|
169
|
+
return type_name, data
|
|
170
|
+
|
|
171
|
+
def loads_typed(self, data: tuple[str, bytes]) -> Any:
|
|
172
|
+
t0 = time.monotonic()
|
|
173
|
+
result = self._inner.loads_typed(data)
|
|
174
|
+
type_name, raw = data
|
|
175
|
+
self._record("loads_typed", type_name, len(raw), time.monotonic() - t0)
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
def dumps(self, obj: Any) -> bytes:
|
|
179
|
+
t0 = time.monotonic()
|
|
180
|
+
data: bytes = self._inner.dumps(obj)
|
|
181
|
+
self._record("dumps", type(obj).__name__, len(data), time.monotonic() - t0)
|
|
182
|
+
return data
|
|
183
|
+
|
|
184
|
+
def loads(self, data: bytes) -> Any:
|
|
185
|
+
t0 = time.monotonic()
|
|
186
|
+
result = self._inner.loads(data)
|
|
187
|
+
self._record("loads", type(result).__name__, len(data), time.monotonic() - t0)
|
|
188
|
+
return result
|
|
189
|
+
|
|
190
|
+
def __getattr__(self, name: str) -> Any:
|
|
191
|
+
# Anything not explicitly wrapped above (e.g. a serde-specific
|
|
192
|
+
# extension method) passes through to the real serde unchanged.
|
|
193
|
+
return getattr(self._inner, name)
|
|
194
|
+
|
|
195
|
+
def _record(
|
|
196
|
+
self, operation: str, type_name: str, byte_size: int, elapsed_secs: float
|
|
197
|
+
) -> None:
|
|
198
|
+
try:
|
|
199
|
+
span = self._tracer.start_span(f"checkpoint:serde:{operation}")
|
|
200
|
+
span.set_attribute("serde.operation", operation)
|
|
201
|
+
span.set_attribute("serde.type", type_name)
|
|
202
|
+
span.set_attribute("serde.byte_size", byte_size)
|
|
203
|
+
span.set_attribute("serde.duration_ms", elapsed_secs * 1000)
|
|
204
|
+
span.end(SpanStatus.OK)
|
|
205
|
+
except Exception:
|
|
206
|
+
logger.debug(
|
|
207
|
+
"agent-trace: failed to record serde %s call", operation, exc_info=True
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ---------------------------------------------------------------------------
|
|
212
|
+
# TracingCheckpointSaver — checkpointer write instrumentation
|
|
213
|
+
# ---------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _make_checkpoint_saver_base() -> type:
|
|
217
|
+
"""Return langgraph.checkpoint.base.BaseCheckpointSaver (lazy import).
|
|
218
|
+
|
|
219
|
+
TracingCheckpointSaver must genuinely subclass BaseCheckpointSaver:
|
|
220
|
+
LangGraph's own Pregel._defaults() gates checkpoint behavior on
|
|
221
|
+
isinstance(checkpointer, BaseCheckpointSaver) (confirmed via direct
|
|
222
|
+
inspection of the installed langgraph.pregel.main), so a duck-typed
|
|
223
|
+
wrapper that merely implements the same methods without the real base
|
|
224
|
+
class would be silently treated as "no checkpointer" by LangGraph.
|
|
225
|
+
"""
|
|
226
|
+
base = _require_checkpoint_base()
|
|
227
|
+
return base.BaseCheckpointSaver # type: ignore[no-any-return]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _build_tracing_checkpoint_saver_class() -> type:
|
|
231
|
+
base_cls = _make_checkpoint_saver_base()
|
|
232
|
+
|
|
233
|
+
class _TracingCheckpointSaverImpl(base_cls): # type: ignore[misc, valid-type]
|
|
234
|
+
"""Concrete implementation — see TracingCheckpointSaver for public docs."""
|
|
235
|
+
|
|
236
|
+
def __init__(self, inner: Any, tracer: Tracer) -> None:
|
|
237
|
+
# Deliberately skip BaseCheckpointSaver.__init__ (it would
|
|
238
|
+
# overwrite self.serde with maybe_add_typed_methods(None or
|
|
239
|
+
# class-level JsonPlusSerializer()) rather than the wrapped
|
|
240
|
+
# checkpointer's own serde).
|
|
241
|
+
self._inner = inner
|
|
242
|
+
self._tracer = tracer
|
|
243
|
+
# Wrap the *inner* checkpointer's own serde in place so
|
|
244
|
+
# serde-boundary calls made internally by inner.put()/.get()
|
|
245
|
+
# (not just calls this wrapper happens to make directly) are
|
|
246
|
+
# captured too — this is what actually closes the "checkpointer
|
|
247
|
+
# serde-boundary capture" gap, not just a wrapper method here.
|
|
248
|
+
if not isinstance(inner.serde, TracingSerde):
|
|
249
|
+
inner.serde = TracingSerde(inner.serde, tracer)
|
|
250
|
+
self.serde = inner.serde
|
|
251
|
+
|
|
252
|
+
# -- delegated read/admin surface (BaseCheckpointSaver defines all of
|
|
253
|
+
# these as real methods that raise NotImplementedError by default, so
|
|
254
|
+
# plain attribute lookup would find the base class's version rather
|
|
255
|
+
# than falling through to __getattr__ — each has to be delegated
|
|
256
|
+
# explicitly). ------------------------------------------------------
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def config_specs(self) -> list[Any]:
|
|
260
|
+
return self._inner.config_specs # type: ignore[no-any-return]
|
|
261
|
+
|
|
262
|
+
def get(self, config: Any) -> Any:
|
|
263
|
+
return self._inner.get(config)
|
|
264
|
+
|
|
265
|
+
def get_tuple(self, config: Any) -> Any:
|
|
266
|
+
return self._inner.get_tuple(config)
|
|
267
|
+
|
|
268
|
+
def list(self, config: Any, **kwargs: Any) -> Any:
|
|
269
|
+
return self._inner.list(config, **kwargs)
|
|
270
|
+
|
|
271
|
+
def delete_thread(self, thread_id: str) -> None:
|
|
272
|
+
self._inner.delete_thread(thread_id)
|
|
273
|
+
|
|
274
|
+
def delete_for_runs(self, run_ids: Any) -> None:
|
|
275
|
+
self._inner.delete_for_runs(run_ids)
|
|
276
|
+
|
|
277
|
+
def copy_thread(self, source_thread_id: str, target_thread_id: str) -> None:
|
|
278
|
+
self._inner.copy_thread(source_thread_id, target_thread_id)
|
|
279
|
+
|
|
280
|
+
def prune(self, thread_ids: Any, **kwargs: Any) -> None:
|
|
281
|
+
self._inner.prune(thread_ids, **kwargs)
|
|
282
|
+
|
|
283
|
+
def get_next_version(self, current: Any, channel: Any = None) -> Any:
|
|
284
|
+
return self._inner.get_next_version(current, channel)
|
|
285
|
+
|
|
286
|
+
async def aget(self, config: Any) -> Any:
|
|
287
|
+
return await self._inner.aget(config)
|
|
288
|
+
|
|
289
|
+
async def aget_tuple(self, config: Any) -> Any:
|
|
290
|
+
return await self._inner.aget_tuple(config)
|
|
291
|
+
|
|
292
|
+
async def alist(self, config: Any, **kwargs: Any) -> Any:
|
|
293
|
+
async for item in self._inner.alist(config, **kwargs):
|
|
294
|
+
yield item
|
|
295
|
+
|
|
296
|
+
async def adelete_thread(self, thread_id: str) -> None:
|
|
297
|
+
await self._inner.adelete_thread(thread_id)
|
|
298
|
+
|
|
299
|
+
async def adelete_for_runs(self, run_ids: Any) -> None:
|
|
300
|
+
await self._inner.adelete_for_runs(run_ids)
|
|
301
|
+
|
|
302
|
+
async def acopy_thread(
|
|
303
|
+
self, source_thread_id: str, target_thread_id: str
|
|
304
|
+
) -> None:
|
|
305
|
+
await self._inner.acopy_thread(source_thread_id, target_thread_id)
|
|
306
|
+
|
|
307
|
+
async def aprune(self, thread_ids: Any, **kwargs: Any) -> None:
|
|
308
|
+
await self._inner.aprune(thread_ids, **kwargs)
|
|
309
|
+
|
|
310
|
+
# -- instrumented write surface --------------------------------------
|
|
311
|
+
|
|
312
|
+
def put(
|
|
313
|
+
self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any
|
|
314
|
+
) -> Any:
|
|
315
|
+
return _record_sync_write(
|
|
316
|
+
self._tracer,
|
|
317
|
+
"checkpoint:put",
|
|
318
|
+
config,
|
|
319
|
+
checkpoint,
|
|
320
|
+
lambda: self._inner.put(config, checkpoint, metadata, new_versions),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
async def aput(
|
|
324
|
+
self, config: Any, checkpoint: Any, metadata: Any, new_versions: Any
|
|
325
|
+
) -> Any:
|
|
326
|
+
return await _record_async_write(
|
|
327
|
+
self._tracer,
|
|
328
|
+
"checkpoint:aput",
|
|
329
|
+
config,
|
|
330
|
+
checkpoint,
|
|
331
|
+
lambda: self._inner.aput(config, checkpoint, metadata, new_versions),
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
def put_writes(
|
|
335
|
+
self, config: Any, writes: Any, task_id: str, task_path: str = ""
|
|
336
|
+
) -> None:
|
|
337
|
+
_record_sync_write(
|
|
338
|
+
self._tracer,
|
|
339
|
+
"checkpoint:put_writes",
|
|
340
|
+
config,
|
|
341
|
+
writes,
|
|
342
|
+
lambda: self._inner.put_writes(config, writes, task_id, task_path),
|
|
343
|
+
task_id=task_id,
|
|
344
|
+
channels=writes,
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
async def aput_writes(
|
|
348
|
+
self, config: Any, writes: Any, task_id: str, task_path: str = ""
|
|
349
|
+
) -> None:
|
|
350
|
+
await _record_async_write(
|
|
351
|
+
self._tracer,
|
|
352
|
+
"checkpoint:aput_writes",
|
|
353
|
+
config,
|
|
354
|
+
writes,
|
|
355
|
+
lambda: self._inner.aput_writes(config, writes, task_id, task_path),
|
|
356
|
+
task_id=task_id,
|
|
357
|
+
channels=writes,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
return _TracingCheckpointSaverImpl
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _channel_names(writes: Any) -> list[str] | None:
|
|
364
|
+
"""Best-effort: [channel for (channel, value) in writes] for a
|
|
365
|
+
put_writes()-shaped `writes` argument, else None."""
|
|
366
|
+
try:
|
|
367
|
+
return [str(w[0]) for w in writes]
|
|
368
|
+
except Exception:
|
|
369
|
+
return None
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _annotate_write_span(
|
|
373
|
+
span: Span,
|
|
374
|
+
config: Any,
|
|
375
|
+
payload: Any,
|
|
376
|
+
*,
|
|
377
|
+
task_id: str | None,
|
|
378
|
+
channels: Any | None,
|
|
379
|
+
) -> None:
|
|
380
|
+
thread_id = _extract_thread_id(config)
|
|
381
|
+
if thread_id is not None:
|
|
382
|
+
span.set_attribute("checkpoint.thread_id", thread_id)
|
|
383
|
+
if task_id is not None:
|
|
384
|
+
span.set_attribute("checkpoint.task_id", str(task_id))
|
|
385
|
+
if channels is not None:
|
|
386
|
+
names = _channel_names(channels)
|
|
387
|
+
if names:
|
|
388
|
+
span.set_attribute("checkpoint.channels", ",".join(names))
|
|
389
|
+
size = _estimate_size(payload)
|
|
390
|
+
if size is not None:
|
|
391
|
+
span.set_attribute("checkpoint.payload_size_bytes", size)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _record_sync_write(
|
|
395
|
+
tracer: Tracer,
|
|
396
|
+
span_name: str,
|
|
397
|
+
config: Any,
|
|
398
|
+
payload: Any,
|
|
399
|
+
fn: Any,
|
|
400
|
+
*,
|
|
401
|
+
task_id: str | None = None,
|
|
402
|
+
channels: Any | None = None,
|
|
403
|
+
) -> Any:
|
|
404
|
+
span = tracer.start_span(span_name)
|
|
405
|
+
_annotate_write_span(span, config, payload, task_id=task_id, channels=channels)
|
|
406
|
+
try:
|
|
407
|
+
result = fn()
|
|
408
|
+
except BaseException as exc:
|
|
409
|
+
span.set_attribute("checkpoint.completed", False)
|
|
410
|
+
_close_write_span_on_error(span, exc)
|
|
411
|
+
raise
|
|
412
|
+
span.set_attribute("checkpoint.completed", True)
|
|
413
|
+
span.end(SpanStatus.OK)
|
|
414
|
+
return result
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
async def _record_async_write(
|
|
418
|
+
tracer: Tracer,
|
|
419
|
+
span_name: str,
|
|
420
|
+
config: Any,
|
|
421
|
+
payload: Any,
|
|
422
|
+
fn: Any,
|
|
423
|
+
*,
|
|
424
|
+
task_id: str | None = None,
|
|
425
|
+
channels: Any | None = None,
|
|
426
|
+
) -> Any:
|
|
427
|
+
span = tracer.start_span(span_name)
|
|
428
|
+
_annotate_write_span(span, config, payload, task_id=task_id, channels=channels)
|
|
429
|
+
try:
|
|
430
|
+
result = await fn()
|
|
431
|
+
except BaseException as exc:
|
|
432
|
+
span.set_attribute("checkpoint.completed", False)
|
|
433
|
+
_close_write_span_on_error(span, exc)
|
|
434
|
+
raise
|
|
435
|
+
span.set_attribute("checkpoint.completed", True)
|
|
436
|
+
span.end(SpanStatus.OK)
|
|
437
|
+
return result
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _close_write_span_on_error(span: Span, exc: BaseException) -> None:
|
|
441
|
+
"""Distinguish a cancelled write (cut off mid-flight — the exact
|
|
442
|
+
cancellation-triggered data-loss shape behind issue #5672) from a
|
|
443
|
+
genuine write failure, mirroring _close_span_with_exception's
|
|
444
|
+
CANCELLED-vs-ERROR split in the callback integration."""
|
|
445
|
+
if isinstance(exc, asyncio.CancelledError):
|
|
446
|
+
span.record_exception(exc, status=SpanStatus.CANCELLED)
|
|
447
|
+
span.end(SpanStatus.CANCELLED)
|
|
448
|
+
else:
|
|
449
|
+
span.record_exception(exc)
|
|
450
|
+
span.end(SpanStatus.ERROR)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
_TracingCheckpointSaverClass: type | None = None
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def TracingCheckpointSaver(inner: Any, tracer: Tracer) -> Any: # noqa: N802
|
|
457
|
+
"""Wrap *inner* (any real ``BaseCheckpointSaver`` instance) so every
|
|
458
|
+
``put``/``aput``/``put_writes``/``aput_writes`` call records a span
|
|
459
|
+
(timestamp via span start/end, payload size, whether the call actually
|
|
460
|
+
completed) while every other method — reads, admin operations, the
|
|
461
|
+
serde boundary — is transparently delegated (with the serde boundary
|
|
462
|
+
itself also instrumented; see ``TracingSerde``).
|
|
463
|
+
|
|
464
|
+
Drop-in replacement for the real checkpointer::
|
|
465
|
+
|
|
466
|
+
real = InMemorySaver()
|
|
467
|
+
traced = TracingCheckpointSaver(real, tracer)
|
|
468
|
+
graph = builder.compile(checkpointer=traced)
|
|
469
|
+
|
|
470
|
+
Returns a genuine ``BaseCheckpointSaver`` subclass instance (required —
|
|
471
|
+
LangGraph's own ``Pregel._defaults()`` gates checkpoint behavior on
|
|
472
|
+
``isinstance(checkpointer, BaseCheckpointSaver)``), built lazily so
|
|
473
|
+
importing this module never requires langgraph to be installed.
|
|
474
|
+
"""
|
|
475
|
+
global _TracingCheckpointSaverClass # noqa: PLW0603
|
|
476
|
+
if _TracingCheckpointSaverClass is None:
|
|
477
|
+
_TracingCheckpointSaverClass = _build_tracing_checkpoint_saver_class()
|
|
478
|
+
return _TracingCheckpointSaverClass(inner, tracer)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# ---------------------------------------------------------------------------
|
|
482
|
+
# traced_update_state / traced_aupdate_state — as_node + task-scheduling
|
|
483
|
+
# ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _record_post_update_schedule(
|
|
487
|
+
span: Span, graph: Any, new_config: Any, requested_as_node: str | None
|
|
488
|
+
) -> None:
|
|
489
|
+
"""Best-effort: read the post-write task schedule via the public
|
|
490
|
+
get_state()/.next surface and flag an empty schedule.
|
|
491
|
+
|
|
492
|
+
Wrapped entirely in try/except: a shape mismatch here (e.g. a future
|
|
493
|
+
LangGraph version renaming StateSnapshot.next) must degrade to "no
|
|
494
|
+
schedule captured", never break the update_state() call it's
|
|
495
|
+
piggybacking on.
|
|
496
|
+
"""
|
|
497
|
+
if requested_as_node is not None:
|
|
498
|
+
span.set_attribute("checkpoint.as_node", str(requested_as_node))
|
|
499
|
+
span.set_attribute("checkpoint.as_node_provided", True)
|
|
500
|
+
else:
|
|
501
|
+
# LangGraph infers as_node internally (e.g. "the last node that
|
|
502
|
+
# updated the state") when the caller doesn't supply one — that
|
|
503
|
+
# inferred value isn't observable from the public update_state()/
|
|
504
|
+
# get_state() surface, so this is honestly reported as "not
|
|
505
|
+
# provided" rather than guessed at.
|
|
506
|
+
span.set_attribute("checkpoint.as_node_provided", False)
|
|
507
|
+
try:
|
|
508
|
+
snapshot = graph.get_state(new_config)
|
|
509
|
+
next_tasks = tuple(getattr(snapshot, "next", None) or ())
|
|
510
|
+
span.set_attribute("checkpoint.next_task_count", len(next_tasks))
|
|
511
|
+
if next_tasks:
|
|
512
|
+
span.set_attribute("checkpoint.next_tasks", ",".join(next_tasks))
|
|
513
|
+
span.set_attribute("checkpoint.zero_tasks_scheduled", len(next_tasks) == 0)
|
|
514
|
+
except Exception:
|
|
515
|
+
logger.debug(
|
|
516
|
+
"agent-trace: failed to read post-update-state task schedule",
|
|
517
|
+
exc_info=True,
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def traced_update_state(
|
|
522
|
+
tracer: Tracer,
|
|
523
|
+
graph: Any,
|
|
524
|
+
config: Any,
|
|
525
|
+
values: Any,
|
|
526
|
+
as_node: str | None = None,
|
|
527
|
+
task_id: str | None = None,
|
|
528
|
+
) -> Any:
|
|
529
|
+
"""Wrap ``graph.update_state(...)``, recording the ``as_node`` the write
|
|
530
|
+
was attributed to (when the caller supplied one explicitly) and the
|
|
531
|
+
pregel scheduler's resulting ``next`` task list immediately afterward —
|
|
532
|
+
flagging ``checkpoint.zero_tasks_scheduled=True`` when that list comes
|
|
533
|
+
back empty, the exact silent-no-op-resume shape behind issue #4217.
|
|
534
|
+
|
|
535
|
+
Usage::
|
|
536
|
+
|
|
537
|
+
new_config = traced_update_state(tracer, graph, config, values,
|
|
538
|
+
as_node="my_node")
|
|
539
|
+
"""
|
|
540
|
+
span = tracer.start_span("checkpoint:update_state")
|
|
541
|
+
try:
|
|
542
|
+
new_config = graph.update_state(
|
|
543
|
+
config, values, as_node=as_node, task_id=task_id
|
|
544
|
+
)
|
|
545
|
+
except BaseException as exc:
|
|
546
|
+
span.record_exception(exc)
|
|
547
|
+
span.end(
|
|
548
|
+
SpanStatus.CANCELLED
|
|
549
|
+
if isinstance(exc, asyncio.CancelledError)
|
|
550
|
+
else SpanStatus.ERROR
|
|
551
|
+
)
|
|
552
|
+
raise
|
|
553
|
+
_record_post_update_schedule(span, graph, new_config, as_node)
|
|
554
|
+
span.end(SpanStatus.OK)
|
|
555
|
+
return new_config
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def traced_aupdate_state(
|
|
559
|
+
tracer: Tracer,
|
|
560
|
+
graph: Any,
|
|
561
|
+
config: Any,
|
|
562
|
+
values: Any,
|
|
563
|
+
as_node: str | None = None,
|
|
564
|
+
task_id: str | None = None,
|
|
565
|
+
) -> Any:
|
|
566
|
+
"""Async equivalent of :func:`traced_update_state`."""
|
|
567
|
+
span = tracer.start_span("checkpoint:update_state")
|
|
568
|
+
try:
|
|
569
|
+
new_config = await graph.aupdate_state(
|
|
570
|
+
config, values, as_node=as_node, task_id=task_id
|
|
571
|
+
)
|
|
572
|
+
except BaseException as exc:
|
|
573
|
+
span.record_exception(exc)
|
|
574
|
+
span.end(
|
|
575
|
+
SpanStatus.CANCELLED
|
|
576
|
+
if isinstance(exc, asyncio.CancelledError)
|
|
577
|
+
else SpanStatus.ERROR
|
|
578
|
+
)
|
|
579
|
+
raise
|
|
580
|
+
try:
|
|
581
|
+
snapshot = await graph.aget_state(new_config)
|
|
582
|
+
except Exception:
|
|
583
|
+
snapshot = None
|
|
584
|
+
_record_post_update_schedule_async(span, snapshot, as_node)
|
|
585
|
+
span.end(SpanStatus.OK)
|
|
586
|
+
return new_config
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _record_post_update_schedule_async(
|
|
590
|
+
span: Span, snapshot: Any, requested_as_node: str | None
|
|
591
|
+
) -> None:
|
|
592
|
+
"""Same bookkeeping as _record_post_update_schedule, but for the async
|
|
593
|
+
path where the snapshot was already fetched via graph.aget_state()."""
|
|
594
|
+
if requested_as_node is not None:
|
|
595
|
+
span.set_attribute("checkpoint.as_node", str(requested_as_node))
|
|
596
|
+
span.set_attribute("checkpoint.as_node_provided", True)
|
|
597
|
+
else:
|
|
598
|
+
span.set_attribute("checkpoint.as_node_provided", False)
|
|
599
|
+
try:
|
|
600
|
+
next_tasks = tuple(getattr(snapshot, "next", None) or ())
|
|
601
|
+
span.set_attribute("checkpoint.next_task_count", len(next_tasks))
|
|
602
|
+
if next_tasks:
|
|
603
|
+
span.set_attribute("checkpoint.next_tasks", ",".join(next_tasks))
|
|
604
|
+
span.set_attribute("checkpoint.zero_tasks_scheduled", len(next_tasks) == 0)
|
|
605
|
+
except Exception:
|
|
606
|
+
logger.debug(
|
|
607
|
+
"agent-trace: failed to read post-update-state task schedule "
|
|
608
|
+
"(async)",
|
|
609
|
+
exc_info=True,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
# ---------------------------------------------------------------------------
|
|
614
|
+
# TracingCache / wrap_cache_policy — CachePolicy hit/miss + key input capture
|
|
615
|
+
# ---------------------------------------------------------------------------
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _format_full_keys(keys: Any) -> str:
|
|
619
|
+
"""Render a sequence of BaseCache FullKey = (Namespace, str) tuples as a
|
|
620
|
+
compact, human-readable string."""
|
|
621
|
+
try:
|
|
622
|
+
return ",".join(f"{'/'.join(ns)}:{key}" for ns, key in keys)
|
|
623
|
+
except Exception:
|
|
624
|
+
return _stringify(keys, max_len=500)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _build_tracing_cache_class() -> type:
|
|
628
|
+
base = _require_cache_base()
|
|
629
|
+
base_cls = base.BaseCache
|
|
630
|
+
|
|
631
|
+
class _TracingCacheImpl(base_cls): # type: ignore[misc, valid-type]
|
|
632
|
+
"""Concrete implementation — see TracingCache for public docs."""
|
|
633
|
+
|
|
634
|
+
def __init__(self, inner: Any, tracer: Tracer) -> None:
|
|
635
|
+
self._inner = inner
|
|
636
|
+
self._tracer = tracer
|
|
637
|
+
self.serde = inner.serde
|
|
638
|
+
|
|
639
|
+
def get(self, keys: Any) -> Any:
|
|
640
|
+
result = self._inner.get(keys)
|
|
641
|
+
self._record_get(keys, result)
|
|
642
|
+
return result
|
|
643
|
+
|
|
644
|
+
async def aget(self, keys: Any) -> Any:
|
|
645
|
+
result = await self._inner.aget(keys)
|
|
646
|
+
self._record_get(keys, result)
|
|
647
|
+
return result
|
|
648
|
+
|
|
649
|
+
def set(self, pairs: Any) -> None:
|
|
650
|
+
self._record_set(pairs)
|
|
651
|
+
self._inner.set(pairs)
|
|
652
|
+
|
|
653
|
+
async def aset(self, pairs: Any) -> None:
|
|
654
|
+
self._record_set(pairs)
|
|
655
|
+
await self._inner.aset(pairs)
|
|
656
|
+
|
|
657
|
+
def clear(self, namespaces: Any = None) -> None:
|
|
658
|
+
self._inner.clear(namespaces)
|
|
659
|
+
|
|
660
|
+
async def aclear(self, namespaces: Any = None) -> None:
|
|
661
|
+
await self._inner.aclear(namespaces)
|
|
662
|
+
|
|
663
|
+
def _record_get(self, keys: Any, result: dict[Any, Any]) -> None:
|
|
664
|
+
try:
|
|
665
|
+
hits = [k for k in keys if k in result]
|
|
666
|
+
misses = [k for k in keys if k not in result]
|
|
667
|
+
span = self._tracer.start_span("cache:get")
|
|
668
|
+
span.set_attribute("cache.hit_count", len(hits))
|
|
669
|
+
span.set_attribute("cache.miss_count", len(misses))
|
|
670
|
+
if hits:
|
|
671
|
+
span.set_attribute("cache.hit_keys", _format_full_keys(hits))
|
|
672
|
+
if misses:
|
|
673
|
+
span.set_attribute("cache.miss_keys", _format_full_keys(misses))
|
|
674
|
+
span.end(SpanStatus.OK)
|
|
675
|
+
except Exception:
|
|
676
|
+
logger.debug(
|
|
677
|
+
"agent-trace: failed to record cache get() call", exc_info=True
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
def _record_set(self, pairs: Any) -> None:
|
|
681
|
+
try:
|
|
682
|
+
keys = list(pairs.keys())
|
|
683
|
+
span = self._tracer.start_span("cache:set")
|
|
684
|
+
span.set_attribute("cache.set_count", len(keys))
|
|
685
|
+
if keys:
|
|
686
|
+
span.set_attribute("cache.set_keys", _format_full_keys(keys))
|
|
687
|
+
span.end(SpanStatus.OK)
|
|
688
|
+
except Exception:
|
|
689
|
+
logger.debug(
|
|
690
|
+
"agent-trace: failed to record cache set() call", exc_info=True
|
|
691
|
+
)
|
|
692
|
+
|
|
693
|
+
return _TracingCacheImpl
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
_TracingCacheClass: type | None = None
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def TracingCache(inner: Any, tracer: Tracer) -> Any: # noqa: N802
|
|
700
|
+
"""Wrap *inner* (any real ``BaseCache`` instance) so every ``get``/
|
|
701
|
+
``aget`` call records a hit/miss-count span and every ``set``/``aset``
|
|
702
|
+
call records a span naming the keys written — the cache hit/miss half of
|
|
703
|
+
the "Hook LangGraph CachePolicy cache hit/miss decisions" gap. Combine
|
|
704
|
+
with :func:`wrap_cache_policy` to also capture the state object/bytes
|
|
705
|
+
LangGraph hashed to compute the cache key.
|
|
706
|
+
|
|
707
|
+
Usage::
|
|
708
|
+
|
|
709
|
+
graph = builder.compile(cache=TracingCache(InMemoryCache(), tracer))
|
|
710
|
+
|
|
711
|
+
Returns a genuine ``BaseCache`` subclass instance, built lazily so
|
|
712
|
+
importing this module never requires langgraph to be installed.
|
|
713
|
+
"""
|
|
714
|
+
global _TracingCacheClass # noqa: PLW0603
|
|
715
|
+
if _TracingCacheClass is None:
|
|
716
|
+
_TracingCacheClass = _build_tracing_cache_class()
|
|
717
|
+
return _TracingCacheClass(inner, tracer)
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def wrap_cache_policy(policy: Any, tracer: Tracer) -> Any:
|
|
721
|
+
"""Return a copy of *policy* (a ``langgraph.types.CachePolicy``) whose
|
|
722
|
+
``key_func`` records the state object it was actually called with — the
|
|
723
|
+
real input LangGraph hashes to compute a node's cache key — alongside
|
|
724
|
+
the resulting key, before delegating to the real ``key_func``.
|
|
725
|
+
|
|
726
|
+
``BaseCache.get()``/``.set()`` (see ``TracingCache`` above) only ever
|
|
727
|
+
see the *already-computed* key; the state object that produced it is
|
|
728
|
+
only observable at the ``key_func`` call site itself, which is exactly
|
|
729
|
+
what this wraps (a copy of the CachePolicy dataclass — a public,
|
|
730
|
+
documented field — not a private pregel internal).
|
|
731
|
+
|
|
732
|
+
Usage::
|
|
733
|
+
|
|
734
|
+
builder.add_node("my_node", my_fn,
|
|
735
|
+
cache_policy=wrap_cache_policy(CachePolicy(), tracer))
|
|
736
|
+
|
|
737
|
+
Returns *policy* unchanged if it is None (mirrors add_node's own
|
|
738
|
+
cache_policy=None default meaning "no caching").
|
|
739
|
+
"""
|
|
740
|
+
if policy is None:
|
|
741
|
+
return None
|
|
742
|
+
import dataclasses
|
|
743
|
+
|
|
744
|
+
original_key_func = policy.key_func
|
|
745
|
+
|
|
746
|
+
def _traced_key_func(*args: Any, **kwargs: Any) -> Any:
|
|
747
|
+
key = original_key_func(*args, **kwargs)
|
|
748
|
+
try:
|
|
749
|
+
span = tracer.start_span("cache:key_func")
|
|
750
|
+
hashed_input = args[0] if args else kwargs
|
|
751
|
+
span.set_attribute("cache.key_input", _to_attr_string(hashed_input))
|
|
752
|
+
span.set_attribute(
|
|
753
|
+
"cache.key",
|
|
754
|
+
key if isinstance(key, str) else _stringify(key, max_len=500),
|
|
755
|
+
)
|
|
756
|
+
span.end(SpanStatus.OK)
|
|
757
|
+
except Exception:
|
|
758
|
+
logger.debug(
|
|
759
|
+
"agent-trace: failed to record cache key_func call", exc_info=True
|
|
760
|
+
)
|
|
761
|
+
return key
|
|
762
|
+
|
|
763
|
+
return dataclasses.replace(policy, key_func=_traced_key_func)
|