From ac5c6b44f2828fbbb4e4f1560cd6eee5cd89ebce Mon Sep 17 00:00:00 2001 From: Nikhil Arora Date: Tue, 1 Sep 2026 22:11:24 +0530 Subject: [PATCH] Add LangGraph adapter, tests, and bump to 0.5.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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__). --- CHANGELOG.md | 19 ++ README.md | 45 +++- logquill/__init__.py | 2 +- logquill/adapters/langgraph.py | 72 ++++++ pyproject.toml | 3 +- tests/test_adapters/test_langgraph_adapter.py | 218 ++++++++++++++++++ 6 files changed, 354 insertions(+), 5 deletions(-) create mode 100644 logquill/adapters/langgraph.py create mode 100644 tests/test_adapters/test_langgraph_adapter.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 925cff2..45c530b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,25 @@ All notable changes to this project are documented in this file. ## Unreleased +## 0.5.0 - 2026-09-01 + +- `LangGraphAdapter` (`pip install logquill[langgraph]`) — corrects an + overstatement in the 0.4.0 entry below: LangGraph nodes run as ordinary + LangChain `Runnable`s, so `LangChainAdapter` alone already captures node + execution, but LangGraph also has its own checkpoint lifecycle — + `on_interrupt`/`on_resume`, fired when a graph pauses on an `interrupt()` + call (e.g. for human review) and later resumes from a persisted + checkpoint — that LangGraph dispatches only to handlers that are + instances of its own `GraphCallbackHandler`; a plain `BaseCallbackHandler` + subclass (all `LangChainAdapter` is) never receives them. `LangGraphAdapter` + is `LangChainAdapter` plus those two, mapped to `.observation + ("graph_interrupted", ...)`/`.action("graph_resumed", ...)` carrying + `checkpoint_id`/`status`/`checkpoint_ns`/pending `Interrupt` payloads, with + the event's own `run_id` as `parent_span_id`. `pip install + logquill[langgraph]` pulls in a compatible `langchain-core` transitively; + `langgraph` is never imported unless `logquill.adapters.langgraph` is + imported explicitly. + ## 0.4.0 - 2026-09-01 - Closed three gaps found auditing Phases 1–3 against their own written diff --git a/README.md b/README.md index d6df063..af58a33 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ for what's landed so far. - **Pluggable formatters** — `JSONFormatter` out of the box; implement `format(record) -> str` for your own - **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) - **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)) -- **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) +- **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) - **Zero required runtime dependencies** — stdlib only; `aiohttp` is opt-in, for async HTTP - **Typed throughout** — `mypy --strict` clean on the public API - *(planned)* non-blocking async dispatch, `contextvars`-based context propagation — see `CHANGELOG.md` @@ -554,8 +554,10 @@ assert record["meta"]["trace_id"] == "4bf92f3577b34da6a3ce929d0e0e4736" `LogQuillAdapter` is a thin base class for mapping a framework's own event callbacks onto `.thought()/.action()/.observation()/.decision()` and `.span()` — never a reimplementation of tracing logic per framework. -`LangChainAdapter` (covers LangGraph for free, since it shares LangChain's -callback system) ships behind the optional `langchain` extra: +`LangChainAdapter` ships behind the optional `langchain` extra. LangGraph +nodes run as ordinary LangChain `Runnable`s, so it already captures node +execution with zero extra work — for LangGraph's own checkpoint +interrupt/resume events too, see [LangGraph](#langgraph) below: ```bash pip install logquill[langchain] @@ -575,6 +577,43 @@ LangChain's own `run_id`/`parent_run_id` are written directly onto field renaming, not translation. `langchain-core` is never imported unless you import `logquill.adapters.langchain` yourself. +### LangGraph + +LangGraph nodes execute as ordinary LangChain `Runnable`s, so +`LangChainAdapter` alone already covers everything that happens *inside* a +node — `on_chain_start`/`on_llm_start`/`on_tool_start`/etc. all fire exactly +as they would for a plain chain. What a plain `BaseCallbackHandler` can't +see is LangGraph's own checkpoint lifecycle: `on_interrupt`/`on_resume`, +fired when a graph pauses on an `interrupt()` call (e.g. for human review) +and later resumes from a persisted checkpoint — LangGraph dispatches those +two specifically to handlers that are instances of its own +`GraphCallbackHandler`, which a plain `BaseCallbackHandler` subclass never +receives. `LangGraphAdapter` is `LangChainAdapter` plus those two: + +```bash +pip install logquill[langgraph] +``` + +```python +from logquill import Logger, RunPlugin +from logquill.adapters.langgraph import LangGraphAdapter + +log = Logger("app") +handler = LangGraphAdapter(log.child("agent").use(RunPlugin())) +graph = builder.compile(checkpointer=checkpointer) +graph.invoke(input, config={"callbacks": [handler], "configurable": {"thread_id": "1"}}) +``` + +`on_interrupt` becomes `.observation("graph_interrupted", ...)` carrying +`checkpoint_id`, `status`, `checkpoint_ns` (the subgraph namespace path, if +nested), and each pending `Interrupt`'s `id`/`value`; `on_resume` becomes +`.action("graph_resumed", ...)` with the same checkpoint fields. Both use +the event's own `run_id` as `parent_span_id`, matching the enclosing +graph's still-open chain span — the graph hasn't ended, just paused. +`pip install logquill[langgraph]` pulls in a compatible `langchain-core` +transitively, so installing it alone is enough; `langgraph` is never +imported unless you import `logquill.adapters.langgraph` yourself. + `CrewAIAdapter` ships behind the optional `crewai` extra, listening on CrewAI's own event bus rather than a single callback handler: diff --git a/logquill/__init__.py b/logquill/__init__.py index c8e08f2..4fd6076 100644 --- a/logquill/__init__.py +++ b/logquill/__init__.py @@ -41,7 +41,7 @@ from logquill.transports.sql.sqlite_transport import SQLiteTransport from logquill.transports.transport import CollectingTransport, Transport -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = [ "AlertingPlugin", diff --git a/logquill/adapters/langgraph.py b/logquill/adapters/langgraph.py new file mode 100644 index 0000000..dbc4f6c --- /dev/null +++ b/logquill/adapters/langgraph.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from typing import Any + +try: + from langgraph.callbacks import GraphCallbackHandler # type: ignore[import-not-found] +except ImportError as exc: + raise ImportError( + "logquill.adapters.langgraph requires the optional `langgraph` " + "dependency — install with `pip install logquill[langgraph]`." + ) from exc + +from logquill.adapters.langchain import LangChainAdapter + + +def _checkpoint_meta(event: Any) -> dict[str, Any]: + meta: dict[str, Any] = {"checkpoint_id": event.checkpoint_id, "status": event.status} + if event.checkpoint_ns: + meta["checkpoint_ns"] = list(event.checkpoint_ns) + if event.run_id is not None: + meta["parent_span_id"] = str(event.run_id) + return meta + + +# `type: ignore[misc]` — same reason as every other adapter here: +# `GraphCallbackHandler` types as `Any` whenever `langgraph` isn't installed +# (optional, never in `dev` — see pyproject.toml), and mypy refuses to let a +# class subclass something typed `Any`. +class LangGraphAdapter(LangChainAdapter, GraphCallbackHandler): # type: ignore[misc] + """`LangChainAdapter` plus LangGraph's own checkpoint pause/resume events. + + `LangChainAdapter` alone already covers everything that happens *inside* + a LangGraph node — nodes execute as ordinary LangChain `Runnable`s, so + `on_chain_start`/`on_llm_start`/`on_tool_start`/etc. all fire exactly as + they would for a plain chain. What a plain `BaseCallbackHandler` cannot + see is LangGraph's own checkpoint lifecycle: `on_interrupt`/`on_resume`, + fired when a graph pauses on an `interrupt()` call (e.g. for human + review) and later resumes from a persisted checkpoint. LangGraph + dispatches those two specifically to handlers that are instances of its + own `GraphCallbackHandler` — a plain `BaseCallbackHandler` subclass + (which is all `LangChainAdapter` is) never receives them, silently. This + class exists for that reason alone; everything else is inherited + unchanged from `LangChainAdapter`. + + from logquill import Logger, RunPlugin + from logquill.adapters.langgraph import LangGraphAdapter + + log = Logger("app") + handler = LangGraphAdapter(log.child("agent").use(RunPlugin())) + graph = builder.compile(checkpointer=checkpointer) + graph.invoke(input, config={"callbacks": [handler], "configurable": {"thread_id": "1"}}) + + `on_interrupt` becomes `.observation("graph_interrupted", ...)` carrying + `checkpoint_id`, `status`, `checkpoint_ns` (the subgraph namespace path, + if nested), and each pending `Interrupt`'s `id`/`value`; `on_resume` + becomes `.action("graph_resumed", ...)` with the same checkpoint fields. + Both use `event.run_id` as `parent_span_id`, matching the enclosing + graph's own chain span from `LangChainAdapter.on_chain_start` — the + graph hasn't ended, just paused, so its span is still open. + + `pip install logquill[langgraph]` — pulls in a compatible `langchain-core` + transitively, so installing this extra alone is enough; `langgraph` is + never imported unless you import `logquill.adapters.langgraph` yourself. + """ + + def on_interrupt(self, event: Any) -> None: + meta = _checkpoint_meta(event) + meta["interrupts"] = [{"id": i.id, "value": i.value} for i in event.interrupts] + self.log.observation("graph_interrupted", **meta) + + def on_resume(self, event: Any) -> None: + self.log.action("graph_resumed", **_checkpoint_meta(event)) diff --git a/pyproject.toml b/pyproject.toml index 14ecfed..b2f22c7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "logquill" -version = "0.4.0" +version = "0.5.0" description = "A structured, leveled logging framework with pluggable transports and a plugin pipeline." readme = "README.md" license = "MIT" @@ -51,6 +51,7 @@ aws = ["boto3>=1.34"] presidio = ["presidio-analyzer>=2.2", "presidio-anonymizer>=2.2"] crypto = ["cryptography>=41"] langchain = ["langchain-core>=0.3"] +langgraph = ["langgraph>=1.0"] crewai = ["crewai>=1.0"] llamaindex = ["llama-index-core>=0.12"] autogen = ["autogen-core>=0.6"] diff --git a/tests/test_adapters/test_langgraph_adapter.py b/tests/test_adapters/test_langgraph_adapter.py new file mode 100644 index 0000000..6264ba5 --- /dev/null +++ b/tests/test_adapters/test_langgraph_adapter.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import importlib +import sys +import types +import uuid +from dataclasses import dataclass +from types import ModuleType + +import pytest + +from logquill.logger import Logger +from logquill.transports.transport import CollectingTransport + + +def _install_fakes(monkeypatch: pytest.MonkeyPatch) -> None: + # Fakes injected via sys.modules — same pattern as `LangChainAdapter`'s + # own tests. `GraphCallbackHandler` must subclass the *same* fake + # `BaseCallbackHandler` that `logquill.adapters.langchain` picks up, + # otherwise `class LangGraphAdapter(LangChainAdapter, GraphCallbackHandler)` + # would sit on two unrelated "BaseCallbackHandler" classes and Python + # would refuse to compute a consistent MRO — exactly mirroring how the + # real `langgraph.callbacks.GraphCallbackHandler` subclasses the real + # `langchain_core.callbacks.BaseCallbackHandler`. + class FakeBaseCallbackHandler: + pass + + class FakeGraphCallbackHandler(FakeBaseCallbackHandler): + def on_interrupt(self, event: object) -> None: # pragma: no cover - default no-op + pass + + def on_resume(self, event: object) -> None: # pragma: no cover - default no-op + pass + + callbacks_module = types.ModuleType("langchain_core.callbacks") + callbacks_module.BaseCallbackHandler = FakeBaseCallbackHandler # type: ignore[attr-defined] + langchain_core_module = types.ModuleType("langchain_core") + langchain_core_module.callbacks = callbacks_module # type: ignore[attr-defined] + + langgraph_callbacks_module = types.ModuleType("langgraph.callbacks") + langgraph_callbacks_module.GraphCallbackHandler = FakeGraphCallbackHandler # type: ignore[attr-defined] + langgraph_module = types.ModuleType("langgraph") + langgraph_module.callbacks = langgraph_callbacks_module # type: ignore[attr-defined] + + monkeypatch.setitem(sys.modules, "langchain_core", langchain_core_module) + monkeypatch.setitem(sys.modules, "langchain_core.callbacks", callbacks_module) + monkeypatch.setitem(sys.modules, "langgraph", langgraph_module) + monkeypatch.setitem(sys.modules, "langgraph.callbacks", langgraph_callbacks_module) + + +def _load_adapter_module(monkeypatch: pytest.MonkeyPatch) -> ModuleType: + _install_fakes(monkeypatch) + langchain_module = importlib.import_module("logquill.adapters.langchain") + importlib.reload(langchain_module) + langgraph_module = importlib.import_module("logquill.adapters.langgraph") + return importlib.reload(langgraph_module) + + +@dataclass +class _FakeInterrupt: + id: str + value: object + + +@dataclass +class _FakeGraphInterruptEvent: + run_id: object + status: str + checkpoint_id: str + checkpoint_ns: tuple + interrupts: tuple + + +@dataclass +class _FakeGraphResumeEvent: + run_id: object + status: str + checkpoint_id: str + checkpoint_ns: tuple + + +def test_raises_an_actionable_error_without_langgraph(monkeypatch: pytest.MonkeyPatch) -> None: + # Deliberately does *not* call `_install_fakes` first: if it did, + # `sys.modules["langgraph.callbacks"]` would still hold the valid fake + # submodule even after the parent `"langgraph"` key below is nulled — + # Python's import machinery checks the submodule's own cache entry + # first and would return it without ever noticing the parent is `None`. + # Nulling both keys directly simulates "genuinely not installed". + monkeypatch.setitem(sys.modules, "langgraph", None) + monkeypatch.setitem(sys.modules, "langgraph.callbacks", None) + monkeypatch.delitem(sys.modules, "logquill.adapters.langgraph", raising=False) + + with pytest.raises(ImportError, match=r"logquill\[langgraph\]"): + importlib.import_module("logquill.adapters.langgraph") + + +def test_still_inherits_langchain_event_mapping(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_adapter_module(monkeypatch) + LangGraphAdapter = module.LangGraphAdapter + + sink = CollectingTransport() + logger = Logger("app.agent", transports=[sink]) + handler = LangGraphAdapter(logger) + + run_id = uuid.uuid4() + handler.on_llm_start({"name": "llm"}, ["hi"], run_id=run_id) + handler.on_llm_end(object(), run_id=run_id) + + start, end = sink.records + assert start["meta"]["kind"] == "action" + assert end["meta"]["kind"] == "observation" + assert start["meta"]["span_id"] == str(run_id) + + +def test_on_interrupt_becomes_an_observation_with_checkpoint_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_adapter_module(monkeypatch) + LangGraphAdapter = module.LangGraphAdapter + + sink = CollectingTransport() + logger = Logger("app.agent", transports=[sink]) + handler = LangGraphAdapter(logger) + + run_id = uuid.uuid4() + event = _FakeGraphInterruptEvent( + run_id=run_id, + status="interrupt_before", + checkpoint_id="chk-1", + checkpoint_ns=("graph", "subgraph"), + interrupts=(_FakeInterrupt(id="int-1", value={"question": "approve?"}),), + ) + + handler.on_interrupt(event) + + assert len(sink.records) == 1 + record = sink.records[0] + assert record["message"] == "graph_interrupted" + assert record["meta"]["kind"] == "observation" + assert record["meta"]["checkpoint_id"] == "chk-1" + assert record["meta"]["status"] == "interrupt_before" + assert record["meta"]["checkpoint_ns"] == ["graph", "subgraph"] + assert record["meta"]["parent_span_id"] == str(run_id) + assert record["meta"]["interrupts"] == [{"id": "int-1", "value": {"question": "approve?"}}] + + +def test_on_resume_becomes_an_action_with_checkpoint_fields( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_adapter_module(monkeypatch) + LangGraphAdapter = module.LangGraphAdapter + + sink = CollectingTransport() + logger = Logger("app.agent", transports=[sink]) + handler = LangGraphAdapter(logger) + + run_id = uuid.uuid4() + event = _FakeGraphResumeEvent( + run_id=run_id, status="pending", checkpoint_id="chk-1", checkpoint_ns=() + ) + + handler.on_resume(event) + + assert len(sink.records) == 1 + record = sink.records[0] + assert record["message"] == "graph_resumed" + assert record["meta"]["kind"] == "action" + assert record["meta"]["checkpoint_id"] == "chk-1" + assert "checkpoint_ns" not in record["meta"] # empty tuple, nothing to nest under + assert record["meta"]["parent_span_id"] == str(run_id) + + +def test_run_id_none_omits_parent_span_id(monkeypatch: pytest.MonkeyPatch) -> None: + module = _load_adapter_module(monkeypatch) + LangGraphAdapter = module.LangGraphAdapter + + sink = CollectingTransport() + logger = Logger("app.agent", transports=[sink]) + handler = LangGraphAdapter(logger) + + event = _FakeGraphResumeEvent( + run_id=None, status="pending", checkpoint_id="chk-1", checkpoint_ns=() + ) + + handler.on_resume(event) + + assert "parent_span_id" not in sink.records[0]["meta"] + + +def test_interrupt_span_shares_the_enclosing_chains_span_via_ambient_context( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The graph's own on_chain_start opens a span; on_interrupt firing while + # it's still open (paused, not ended) should see the same run_id either + # way — this just confirms the two don't conflict when both fire. + module = _load_adapter_module(monkeypatch) + LangGraphAdapter = module.LangGraphAdapter + + sink = CollectingTransport() + logger = Logger("app.agent", transports=[sink]) + handler = LangGraphAdapter(logger) + + chain_run = uuid.uuid4() + handler.on_chain_start({"name": "graph"}, {}, run_id=chain_run) + handler.on_interrupt( + _FakeGraphInterruptEvent( + run_id=chain_run, + status="interrupt_before", + checkpoint_id="chk-1", + checkpoint_ns=(), + interrupts=(), + ) + ) + handler.on_chain_end({}, run_id=chain_run) + + interrupt_record, chain_close = sink.records + assert interrupt_record["meta"]["parent_span_id"] == str(chain_run) + assert chain_close["meta"]["span_id"] == str(chain_run)