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)