Skip to content

Commit ec6c53f

Browse files
authored
Merge pull request #9 from nikhilvdev/feat/langgraph-adapter
Add LangGraph adapter, tests, and bump to 0.5.0
2 parents 8a85c04 + ac5c6b4 commit ec6c53f

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)