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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,28 @@ All notable changes to this project are documented in this file.

## Unreleased

- Auto-instrumentation, an OpenAI Agents SDK adapter, and MCP trace propagation:
- `logquill.instrument.anthropic(logger)` / `.openai(logger)` / `.litellm(logger)`
patch the Anthropic, OpenAI, and litellm Python SDKs so every LLM call they
make anywhere in the process emits a `logger.llm_call(...)` — no call-site
changes. Each has a matching `.uninstrument()`, lives behind its own
optional extra (`logquill[instrument-anthropic]`, `[instrument-openai]`,
`[instrument-litellm]`), and is imported lazily: `import logquill.instrument`
never imports a provider SDK. Streaming calls are a documented gap in this
release — passed through untouched rather than partially instrumented.
- `OpenAIAgentsAdapter` (`pip install logquill[openai-agents]`) maps the
OpenAI Agents SDK's `RunHooks` the same way the existing adapters map their
frameworks: every agent activation (including a handoff's target) is its
own `invoke_agent` span, and `on_llm_end` becomes a real `.llm_call()` with
token usage, so `OTLPTransport` exports a full run — spans, tokens, cost —
with zero manual logging calls.
- `logquill.mcp` (`propagate()`/`inbound()`) propagates trace context over an
MCP request's `_meta` field and stamps `meta.mcp.*` on records logged while
handling one, with no dependency on the `mcp` package itself. A client's
`run_id` rides along informationally as `meta.mcp.run_id`; it never
overrides the handling process's own `RunPlugin` run id.
- The record contract gained `meta.mcp.run_id`.

- LLM calls and OpenTelemetry export:
- `logger.llm_call(model=, tokens_in=, tokens_out=, cost_usd=, latency_ms=,
finish_reason=)` records an LLM call with its numbers in the record's
Expand Down
2 changes: 1 addition & 1 deletion MIGRATING.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ has them unless you use those.
| `llm.cost_usd`, `llm.latency_ms` | `llm` block | number ≥ 0 |
| `meta.retry_count` | `meta` | integer ≥ 0 |
| `meta.state_diff` | `meta` | object |
| `meta.mcp.server`, `meta.mcp.tool` | `meta` | string |
| `meta.mcp.server`, `meta.mcp.tool`, `meta.mcp.run_id` | `meta` | string |
| `meta.tool`, `meta.tool_call_id`, `meta.provider`, `meta.agent_name`, `meta.agent_id`, `meta.response_model` | `meta` | string |
| `meta.operation` | `meta` | `chat`, `text_completion` or `invoke_agent` |
| `meta.input_messages`, `meta.output_messages` | `meta` | array (opt-in prompt/completion content) |
Expand Down
88 changes: 87 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,9 @@ for what's landed so far.
- **Pluggable formatters** — `JSONFormatter` (default, machine-readable), `TextFormatter` (human-readable, for terminals), and `LogfmtFormatter` (`key=value`, the Heroku/Go convention); implement `format(record) -> str` for your own — see [Formatters](#formatters)
- **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]`), `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)
- **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]`), `AutoGenAdapter` (`pip install logquill[autogen]`), and `OpenAIAgentsAdapter` (`pip install logquill[openai-agents]`) — see [Agentic & harness tracing](#agentic--harness-tracing)
- **LLM calls & OpenTelemetry** — `logger.llm_call(model=, tokens_in=, tokens_out=, cost_usd=, ...)` records a first-class `llm` block, `span(capture_state=...)` records what state changed, repeated tool calls get `meta.retry_count` automatically, and `OTLPTransport` (`pip install logquill[otel]`) exports it all as real OpenTelemetry spans named and attributed per the GenAI semantic conventions; `OTelLogsTransport` sends records as OTLP logs — see [LLM calls & OpenTelemetry](#llm-calls--opentelemetry)
- **Auto-instrumentation & MCP** — `logquill.instrument.anthropic(logger)`/`.openai(logger)`/`.litellm(logger)` patch a provider SDK so every LLM call logs itself, no call-site changes; `OpenAIAgentsAdapter` covers the OpenAI Agents SDK; `logquill.mcp` propagates trace context over MCP requests and stamps `meta.mcp.*` — see [Auto-instrumentation and MCP](#auto-instrumentation-and-mcp)
- **Non-blocking async dispatch** — `Logger(async_dispatch=True)` moves transport writes onto a background thread with a bounded queue and a configurable backpressure policy (`drop_oldest`/`drop_newest`/`block`); `flush()`/`flush_async()` and a `with_lambda`/`with_cloud_function`/`with_azure_function` decorator make serverless shutdown safe — see [Async dispatch & serverless safety](#async-dispatch--serverless-safety)
- **Zero required runtime dependencies** — stdlib only; `aiohttp` (`logquill[http]`) is opt-in, for keep-alive HTTP delivery
- **Typed throughout** — `mypy --strict` clean on the public API
Expand Down Expand Up @@ -695,6 +696,29 @@ 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.

`OpenAIAgentsAdapter` maps the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/)'s
`RunHooks` the same way — pass an instance as `Runner.run(..., hooks=...)`:

```bash
pip install logquill[openai-agents]
```

```python
from agents import Agent, Runner
from logquill import Logger, RunPlugin
from logquill.adapters.openai_agents import OpenAIAgentsAdapter

log = Logger("app")
hooks = OpenAIAgentsAdapter(log.child("agent").use(RunPlugin()))
agent = Agent(name="assistant", instructions="...")
result = await Runner.run(agent, "hello", hooks=hooks)
```

Every agent activation (including a handoff's target) gets its own
`invoke_agent {agent.name}` span; `on_llm_end` becomes a real `.llm_call()`
with token usage, so `OTLPTransport` exports the whole run with cost and
token fields attached, with zero manual logging calls.

### LangGraph

LangGraph nodes execute as ordinary LangChain `Runnable`s, so
Expand Down Expand Up @@ -948,6 +972,68 @@ Three things worth knowing:
and one run is one trace. Without either, spans that name a parent still share
a trace with their siblings, but not with their grandparents.

## Auto-instrumentation and MCP

**Auto-instrumentation.** `logquill.instrument.<provider>(logger)` patches a
provider SDK so every LLM call it makes anywhere in the process logs itself —
no call-site changes, and no code path forgets to log. Each provider lives
behind its own extra and is imported lazily; `import logquill.instrument`
never imports a provider SDK:

```bash
pip install logquill[instrument-anthropic] # or [instrument-openai] / [instrument-litellm]
```

```python
import logquill.instrument as instrument
from logquill import Logger

logger = Logger("app")
instrument.anthropic(logger)

# unchanged call site — no logger, no llm_call(), nothing added here
response = client.messages.create(model="...", max_tokens=100, messages=[...])

instrument.anthropic.uninstrument() # each instrumenter has a matching uninstrument()
```

`logquill.instrument.litellm` is the broadest one: since litellm itself fans
out to 100+ providers behind one interface, instrumenting it covers all of
them from this one call. Calling an instrumenter twice without an
intervening `.uninstrument()` raises, so a call is never wrapped twice.
**Streaming calls are a known, documented gap** in this release — a
`stream=True` call passes through untouched rather than being partially
instrumented; see each provider module's docstring
(`logquill.instrument.anthropic`, `.openai`, `.litellm`).

**MCP (Model Context Protocol).** `logquill.mcp` propagates trace context
over an MCP request and stamps `meta.mcp.*` on records logged while handling
one — with no dependency on the `mcp` package itself, since both sides just
build or read a plain dict (exactly what an MCP client's `meta=` argument and
a server's inbound `_meta` are):

```python
from logquill import Logger, TraceContextPlugin
from logquill.mcp import inbound, propagate

# client, before calling a tool:
await session.call_tool("search", {"q": "..."}, meta=propagate(run_id=agent_run_id))

# server, inside the tool handler:
server_log = Logger("mcp.files", plugins=[TraceContextPlugin()])
with inbound(ctx.request_context.meta, server="files", tool="search"):
server_log.info("handling search") # meta.trace_id matches the client's own
# call, and meta.mcp = {"server": "files",
# "tool": "search", "run_id": agent_run_id}
```

`propagate()`'s keys are namespaced under `logquill/`, an unreserved `_meta`
prefix per the MCP spec (reserved prefixes contain a
`modelcontextprotocol`/`mcp` label). A client's `run_id` never overrides the
server's own `RunPlugin` run id — it rides along informationally, under
`meta.mcp.run_id`, since the server handling a tool call is its own run, not
a continuation of the client's.

## Async dispatch & serverless safety

By default, every log call dispatches to its transports synchronously — a
Expand Down
156 changes: 156 additions & 0 deletions logquill/adapters/openai_agents.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
"""`OpenAIAgentsAdapter` — translates the OpenAI Agents SDK's `RunHooks`
lifecycle callbacks into `.thought()/.action()/.observation()/.decision()`
and `span()` calls, per `LogQuillAdapter`'s "thin mapping, not a
reimplementation" contract.

Requires the optional `openai-agents` package (`pip install
logquill[openai-agents]`), imported lazily — importing this module before
it's installed raises an actionable `ImportError`, and importing `logquill`
itself never imports `agents`.
"""

from __future__ import annotations

import time
from typing import Any

try:
from agents.lifecycle import RunHooks
except ImportError as exc:
raise ImportError(
"logquill.adapters.openai_agents requires the optional `openai-agents` "
"package — install with `pip install logquill[openai-agents]`."
) from exc

from logquill.adapters.base import LogQuillAdapter
from logquill.logger import Logger
from logquill.span import SpanContext, new_span_id


def _agent_name(agent: Any) -> str:
name = getattr(agent, "name", None)
return name if isinstance(name, str) and name else "agent"


def _tool_name(tool: Any) -> str:
name = getattr(tool, "name", None)
return name if isinstance(name, str) and name else "tool"


def _usage_fields(response: Any) -> dict[str, Any]:
usage = getattr(response, "usage", None)
return {
"tokens_in": getattr(usage, "input_tokens", None),
"tokens_out": getattr(usage, "output_tokens", None),
}


# `type: ignore[misc]` — `RunHooks` types as `Any` whenever `openai-agents`
# isn't installed in the environment running mypy (an optional dependency,
# never in this project's `dev` extra — see pyproject.toml), and mypy refuses
# to let a class subclass something typed `Any`. With the real package
# installed, this subclasses the genuine `RunHooks` and the ignore is inert.
class OpenAIAgentsAdapter(LogQuillAdapter, RunHooks): # type: ignore[misc]
"""Pass an instance as `Runner.run(..., hooks=...)` — no separate
registration step needed.

| OpenAI Agents SDK hook | LogQuill call |
|------------------------------------|------------------------------------|
| `on_agent_start` / `on_agent_end` | opens/closes a `span()` |
| `on_llm_end` | `.llm_call()` with token usage and elapsed time |
| `on_tool_start` / `on_tool_end` | `.action(tool=...)` / `.observation(tool=...)` |
| `on_handoff` | `.decision()` naming `from_agent`/`to_agent` |

Every agent activation gets its own `invoke_agent {agent.name}` span, not
just the outermost — a handoff's target is its own nested span, never a
reuse of its predecessor's. `on_llm_end`'s token counts and `duration_ms`
(timed since the matching `on_llm_start`) come straight from
`ModelResponse.usage`. Tagging `.action()`/`.observation()` with `tool=`
is what makes them show up as `execute_tool` spans under `OTLPTransport`
and get `retry_count` tracked automatically.

A run's `RunContextWrapper`/`AgentHookContext` has no id of its own to
key spans by (unlike LangChain's `run_id`), so this adapter uses the
context object's identity — one `Runner.run()` call reuses one context
throughout, so nesting (including across a handoff) still comes out
right. The one sharp edge: if the *same* tool is invoked twice
concurrently in one run, their `on_tool_start`/`on_tool_end` pairs can
cross-attribute duration, since the SDK's hooks don't hand this adapter
a per-call id to key on — sequential calls (the common case, and the
SDK's default) are unaffected.
"""

def __init__(self, agent_log: Logger) -> None:
LogQuillAdapter.__init__(self, agent_log)
self._agent_spans: dict[int, list[SpanContext]] = {}
self._llm_starts: dict[int, float] = {}
self._tool_spans: dict[tuple[int, str], list[float]] = {}

def _stack(self, context: Any) -> list[SpanContext]:
return self._agent_spans.setdefault(id(context), [])

async def on_agent_start(self, context: Any, agent: Any) -> None:
"""Opens a span for this agent's activation, nested under whichever
agent (if any) is already active in this run — including a handoff's
target, which gets its own nested span rather than reusing its
predecessor's."""
stack = self._stack(context)
parent_span_id = stack[-1]._span_id if stack else None
span = self.log.span(
_agent_name(agent),
span_id=new_span_id(),
parent_span_id=parent_span_id,
operation="invoke_agent",
agent_name=_agent_name(agent),
)
span.__enter__()
stack.append(span)

async def on_agent_end(self, context: Any, agent: Any, output: Any) -> None:
"""Closes the span opened by the matching `on_agent_start`."""
stack = self._stack(context)
if stack:
stack.pop().__exit__(None, None, None)
if not stack:
self._agent_spans.pop(id(context), None)

async def on_llm_start(
self, context: Any, agent: Any, system_prompt: str | None, input_items: Any
) -> None:
"""Records the call's start time, for the matching `on_llm_end`'s
`duration_ms`."""
self._llm_starts[id(context)] = time.monotonic()

async def on_llm_end(self, context: Any, agent: Any, response: Any) -> None:
"""Emits `.llm_call()` with token usage and elapsed time."""
start = self._llm_starts.pop(id(context), None)
duration_ms = round((time.monotonic() - start) * 1000, 3) if start is not None else None
model = getattr(agent, "model", None)
self.log.llm_call(
model=model if isinstance(model, str) else None,
latency_ms=duration_ms,
**_usage_fields(response),
)

async def on_tool_start(self, context: Any, agent: Any, tool: Any) -> None:
"""Emits `.action(tool=...)` and records the call's start time."""
name = _tool_name(tool)
self._tool_spans.setdefault((id(context), name), []).append(time.monotonic())
self.log.action(f"call {name}", tool=name, agent_name=_agent_name(agent))

async def on_tool_end(self, context: Any, agent: Any, tool: Any, result: Any) -> None:
"""Emits `.observation(tool=...)` with `duration_ms` measured since
the matching `on_tool_start` — see the class docstring for the one
case (concurrent calls of the same tool) this timing can misattribute."""
name = _tool_name(tool)
starts = self._tool_spans.get((id(context), name))
start = starts.pop() if starts else None
duration_ms = round((time.monotonic() - start) * 1000, 3) if start is not None else None
self.log.observation(f"{name} done", tool=name, duration_ms=duration_ms)

async def on_handoff(self, context: Any, from_agent: Any, to_agent: Any) -> None:
"""Emits `.decision()` naming the handoff's source and destination
agents."""
self.log.decision(
"handoff", from_agent=_agent_name(from_agent), to_agent=_agent_name(to_agent)
)
30 changes: 30 additions & 0 deletions logquill/instrument/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""One-line instrumentation for provider SDKs: patch a client library so every
LLM call it makes emits `logger.llm_call(...)` — token counts, model, finish
reason, latency — with no call-site changes anywhere in your code.

import logquill.instrument as instrument

instrument.anthropic(agent_log)
# every client.messages.create(...) anywhere in the process now logs itself
...
instrument.anthropic.uninstrument()

Each provider lives behind its own optional extra (`pip install
logquill[instrument-anthropic]`, `[instrument-openai]`, `[instrument-litellm]`)
and is imported lazily, inside the call to `instrument.<provider>(logger)` —
**importing `logquill`, or `logquill.instrument`, never imports a provider
SDK.** Calling an instrumenter twice without a matching `.uninstrument()`
raises, so a call is never wrapped twice.

Every instrumenter currently covers non-streaming calls only; a streaming
call is passed through untouched rather than partially instrumented — see
each provider module's docstring.
"""

from __future__ import annotations

from logquill.instrument.anthropic import anthropic
from logquill.instrument.litellm import litellm
from logquill.instrument.openai import openai

__all__ = ["anthropic", "litellm", "openai"]
Loading
Loading