Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 42 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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]
Expand All @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion logquill/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions logquill/adapters/langgraph.py
Original file line number Diff line number Diff line change
@@ -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))
3 changes: 2 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"]
Expand Down
Loading