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