Skip to content

Commit ac5c6b4

Browse files
committed
Add LangGraph adapter, tests, and bump to 0.5.0
Introduce logquill.adapters.langgraph: LangGraphAdapter extends LangChainAdapter and GraphCallbackHandler to map LangGraph checkpoint events (on_interrupt → observation 'graph_interrupted', on_resume → action 'graph_resumed') into LogQuill records. Adds actionable ImportError when langgraph isn't installed. Add tests for the adapter, update README and CHANGELOG with LangGraph docs, add optional 'langgraph' extra in pyproject.toml, and bump package version to 0.5.0 (pyproject + __init__).
1 parent 8b3ac6f commit ac5c6b4

6 files changed

Lines changed: 354 additions & 5 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ All notable changes to this project are documented in this file.
44

55
## Unreleased
66

7+
## 0.5.0 - 2026-09-01
8+
9+
- `LangGraphAdapter` (`pip install logquill[langgraph]`) — corrects an
10+
overstatement in the 0.4.0 entry below: LangGraph nodes run as ordinary
11+
LangChain `Runnable`s, so `LangChainAdapter` alone already captures node
12+
execution, but LangGraph also has its own checkpoint lifecycle —
13+
`on_interrupt`/`on_resume`, fired when a graph pauses on an `interrupt()`
14+
call (e.g. for human review) and later resumes from a persisted
15+
checkpoint — that LangGraph dispatches only to handlers that are
16+
instances of its own `GraphCallbackHandler`; a plain `BaseCallbackHandler`
17+
subclass (all `LangChainAdapter` is) never receives them. `LangGraphAdapter`
18+
is `LangChainAdapter` plus those two, mapped to `.observation
19+
("graph_interrupted", ...)`/`.action("graph_resumed", ...)` carrying
20+
`checkpoint_id`/`status`/`checkpoint_ns`/pending `Interrupt` payloads, with
21+
the event's own `run_id` as `parent_span_id`. `pip install
22+
logquill[langgraph]` pulls in a compatible `langchain-core` transitively;
23+
`langgraph` is never imported unless `logquill.adapters.langgraph` is
24+
imported explicitly.
25+
726
## 0.4.0 - 2026-09-01
827

928
- Closed three gaps found auditing Phases 1–3 against their own written

‎README.md‎

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ for what's landed so far.
2626
- **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own
2727
- **Config from file/env** — `load_config(dict)`, `logger_from_file(path)` (JSON/YAML), `logger_from_env()` build a `Logger` from one config shape — see [Config](#config)
2828
- **Plugin pipeline** — `ContextPlugin`, `RedactPlugin` (by key), `PIIRedactPlugin` (by pattern), `SamplingPlugin` (with tail-based elevation), `TamperEvidentPlugin` (hash-chained logs), `TraceContextPlugin` (cross-service trace correlation), and `AlertingPlugin` (`SlackAlertPlugin`/`PagerDutyAlertPlugin`/`EmailAlertPlugin`, deduplicated) out of the box; a broken plugin can't crash logging; `.use()` also accepts a plain function, no subclassing required (see [Plugins](#plugins))
29-
- **Agentic & harness tracing** — `.child()` loggers, `RunPlugin`, `.thought()/.action()/.observation()/.decision()`, `with agent_log.span(...)`, and framework adapters — `LangChainAdapter` (`pip install logquill[langchain]`, covers LangGraph for free), `CrewAIAdapter` (`pip install logquill[crewai]`), `LlamaIndexAdapter` (`pip install logquill[llamaindex]`), and `AutoGenAdapter` (`pip install logquill[autogen]`) — see [Agentic & harness tracing](#agentic--harness-tracing)
29+
- **Agentic & harness tracing** — `.child()` loggers, `RunPlugin`, `.thought()/.action()/.observation()/.decision()`, `with agent_log.span(...)`, and framework adapters — `LangChainAdapter` (`pip install logquill[langchain]`), `LangGraphAdapter` (`pip install logquill[langgraph]`, adds checkpoint interrupt/resume events on top), `CrewAIAdapter` (`pip install logquill[crewai]`), `LlamaIndexAdapter` (`pip install logquill[llamaindex]`), and `AutoGenAdapter` (`pip install logquill[autogen]`) — see [Agentic & harness tracing](#agentic--harness-tracing)
3030
- **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP
3131
- **Typed throughout** — `mypy --strict` clean on the public API
3232
- *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md`
@@ -554,8 +554,10 @@ assert record["meta"]["trace_id"] == "4bf92f3577b34da6a3ce929d0e0e4736"
554554
`LogQuillAdapter` is a thin base class for mapping a framework's own event
555555
callbacks onto `.thought()/.action()/.observation()/.decision()` and
556556
`.span()` — never a reimplementation of tracing logic per framework.
557-
`LangChainAdapter` (covers LangGraph for free, since it shares LangChain's
558-
callback system) ships behind the optional `langchain` extra:
557+
`LangChainAdapter` ships behind the optional `langchain` extra. LangGraph
558+
nodes run as ordinary LangChain `Runnable`s, so it already captures node
559+
execution with zero extra work — for LangGraph's own checkpoint
560+
interrupt/resume events too, see [LangGraph](#langgraph) below:
559561

560562
```bash
561563
pip install logquill[langchain]
@@ -575,6 +577,43 @@ LangChain's own `run_id`/`parent_run_id` are written directly onto
575577
field renaming, not translation. `langchain-core` is never imported unless
576578
you import `logquill.adapters.langchain` yourself.
577579

580+
### LangGraph
581+
582+
LangGraph nodes execute as ordinary LangChain `Runnable`s, so
583+
`LangChainAdapter` alone already covers everything that happens *inside* a
584+
node — `on_chain_start`/`on_llm_start`/`on_tool_start`/etc. all fire exactly
585+
as they would for a plain chain. What a plain `BaseCallbackHandler` can't
586+
see is LangGraph's own checkpoint lifecycle: `on_interrupt`/`on_resume`,
587+
fired when a graph pauses on an `interrupt()` call (e.g. for human review)
588+
and later resumes from a persisted checkpoint — LangGraph dispatches those
589+
two specifically to handlers that are instances of its own
590+
`GraphCallbackHandler`, which a plain `BaseCallbackHandler` subclass never
591+
receives. `LangGraphAdapter` is `LangChainAdapter` plus those two:
592+
593+
```bash
594+
pip install logquill[langgraph]
595+
```
596+
597+
```python
598+
from logquill import Logger, RunPlugin
599+
from logquill.adapters.langgraph import LangGraphAdapter
600+
601+
log = Logger("app")
602+
handler = LangGraphAdapter(log.child("agent").use(RunPlugin()))
603+
graph = builder.compile(checkpointer=checkpointer)
604+
graph.invoke(input, config={"callbacks": [handler], "configurable": {"thread_id": "1"}})
605+
```
606+
607+
`on_interrupt` becomes `.observation("graph_interrupted", ...)` carrying
608+
`checkpoint_id`, `status`, `checkpoint_ns` (the subgraph namespace path, if
609+
nested), and each pending `Interrupt`'s `id`/`value`; `on_resume` becomes
610+
`.action("graph_resumed", ...)` with the same checkpoint fields. Both use
611+
the event's own `run_id` as `parent_span_id`, matching the enclosing
612+
graph's still-open chain span — the graph hasn't ended, just paused.
613+
`pip install logquill[langgraph]` pulls in a compatible `langchain-core`
614+
transitively, so installing it alone is enough; `langgraph` is never
615+
imported unless you import `logquill.adapters.langgraph` yourself.
616+
578617
`CrewAIAdapter` ships behind the optional `crewai` extra, listening on
579618
CrewAI's own event bus rather than a single callback handler:
580619

‎logquill/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from logquill.transports.sql.sqlite_transport import SQLiteTransport
4242
from logquill.transports.transport import CollectingTransport, Transport
4343

44-
__version__ = "0.4.0"
44+
__version__ = "0.5.0"
4545

4646
__all__ = [
4747
"AlertingPlugin",

‎logquill/adapters/langgraph.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
from __future__ import annotations
2+
3+
from typing import Any
4+
5+
try:
6+
from langgraph.callbacks import GraphCallbackHandler # type: ignore[import-not-found]
7+
except ImportError as exc:
8+
raise ImportError(
9+
"logquill.adapters.langgraph requires the optional `langgraph` "
10+
"dependency — install with `pip install logquill[langgraph]`."
11+
) from exc
12+
13+
from logquill.adapters.langchain import LangChainAdapter
14+
15+
16+
def _checkpoint_meta(event: Any) -> dict[str, Any]:
17+
meta: dict[str, Any] = {"checkpoint_id": event.checkpoint_id, "status": event.status}
18+
if event.checkpoint_ns:
19+
meta["checkpoint_ns"] = list(event.checkpoint_ns)
20+
if event.run_id is not None:
21+
meta["parent_span_id"] = str(event.run_id)
22+
return meta
23+
24+
25+
# `type: ignore[misc]` — same reason as every other adapter here:
26+
# `GraphCallbackHandler` types as `Any` whenever `langgraph` isn't installed
27+
# (optional, never in `dev` — see pyproject.toml), and mypy refuses to let a
28+
# class subclass something typed `Any`.
29+
class LangGraphAdapter(LangChainAdapter, GraphCallbackHandler): # type: ignore[misc]
30+
"""`LangChainAdapter` plus LangGraph's own checkpoint pause/resume events.
31+
32+
`LangChainAdapter` alone already covers everything that happens *inside*
33+
a LangGraph node — nodes execute as ordinary LangChain `Runnable`s, so
34+
`on_chain_start`/`on_llm_start`/`on_tool_start`/etc. all fire exactly as
35+
they would for a plain chain. What a plain `BaseCallbackHandler` cannot
36+
see is LangGraph's own checkpoint lifecycle: `on_interrupt`/`on_resume`,
37+
fired when a graph pauses on an `interrupt()` call (e.g. for human
38+
review) and later resumes from a persisted checkpoint. LangGraph
39+
dispatches those two specifically to handlers that are instances of its
40+
own `GraphCallbackHandler` — a plain `BaseCallbackHandler` subclass
41+
(which is all `LangChainAdapter` is) never receives them, silently. This
42+
class exists for that reason alone; everything else is inherited
43+
unchanged from `LangChainAdapter`.
44+
45+
from logquill import Logger, RunPlugin
46+
from logquill.adapters.langgraph import LangGraphAdapter
47+
48+
log = Logger("app")
49+
handler = LangGraphAdapter(log.child("agent").use(RunPlugin()))
50+
graph = builder.compile(checkpointer=checkpointer)
51+
graph.invoke(input, config={"callbacks": [handler], "configurable": {"thread_id": "1"}})
52+
53+
`on_interrupt` becomes `.observation("graph_interrupted", ...)` carrying
54+
`checkpoint_id`, `status`, `checkpoint_ns` (the subgraph namespace path,
55+
if nested), and each pending `Interrupt`'s `id`/`value`; `on_resume`
56+
becomes `.action("graph_resumed", ...)` with the same checkpoint fields.
57+
Both use `event.run_id` as `parent_span_id`, matching the enclosing
58+
graph's own chain span from `LangChainAdapter.on_chain_start` — the
59+
graph hasn't ended, just paused, so its span is still open.
60+
61+
`pip install logquill[langgraph]` — pulls in a compatible `langchain-core`
62+
transitively, so installing this extra alone is enough; `langgraph` is
63+
never imported unless you import `logquill.adapters.langgraph` yourself.
64+
"""
65+
66+
def on_interrupt(self, event: Any) -> None:
67+
meta = _checkpoint_meta(event)
68+
meta["interrupts"] = [{"id": i.id, "value": i.value} for i in event.interrupts]
69+
self.log.observation("graph_interrupted", **meta)
70+
71+
def on_resume(self, event: Any) -> None:
72+
self.log.action("graph_resumed", **_checkpoint_meta(event))

‎pyproject.toml‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "logquill"
7-
version = "0.4.0"
7+
version = "0.5.0"
88
description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline."
99
readme = "README.md"
1010
license = "MIT"
@@ -51,6 +51,7 @@ aws = ["boto3>=1.34"]
5151
presidio = ["presidio-analyzer>=2.2", "presidio-anonymizer>=2.2"]
5252
crypto = ["cryptography>=41"]
5353
langchain = ["langchain-core>=0.3"]
54+
langgraph = ["langgraph>=1.0"]
5455
crewai = ["crewai>=1.0"]
5556
llamaindex = ["llama-index-core>=0.12"]
5657
autogen = ["autogen-core>=0.6"]

0 commit comments

Comments
 (0)