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

## Unreleased

- Phase 7, advanced context & stdlib bridge, complete:
- `bind_context(**values)` — a `contextvars`-based context manager that
merges `values` into every `Logger` call underneath it, through any
method and any number of function calls deep, without threading them
through each signature by hand. Isolated per thread/asyncio task;
nested blocks merge, with the innermost value winning on key collision
(an explicit call-site `meta` value still wins over anything bound this
way). `current_context()` reads the merged dict directly.
- `exc_info=` on every `Logger` method (`logger.error("failed",
exc_info=e)`) — accepts the same shapes stdlib `logging` does (an
exception instance, `True` for the exception currently being handled,
or an explicit `(type, value, traceback)` tuple), formats a traceback
into `meta["stack"]`, and is never kept in `meta` as the raw exception
object, since that isn't serializable. `format_exc_info()` is exported
directly for anything that wants the same formatting standalone.
- `LogQuillHandler` — a `logging.Handler` subclass that bridges stdlib
`logging` calls (including from third-party libraries) into a LogQuill
`Logger`, so they flow through the same transports and plugin pipeline
as a native `.info()`/`.error()`/... call. `extra=` fields land in
`meta`; an attached `exc_info` is formatted into `meta["stack"]` the
same way the `Logger`'s own `exc_info=` kwarg is. Level filtering still
applies on top of whatever the stdlib logger/handler's own level is set to.
- `RateLimitPlugin(max_records, per_seconds)` — drops records once a key
(by default `(logger, level)`, or a custom `key_func`) exceeds
`max_records` within a rolling per-key window, to cap a noisy loop
without silencing the logger's other messages. Bounded by `max_keys`
distinct keys tracked at once, evicting the least-recently-seen key's
window to make room for a new one.
- Phase 6, async worker, shutdown & serverless safety, complete:
- `AsyncWorker` — a bounded, in-memory queue backed by a single daemon
thread, with a configurable `backpressure` policy for what happens once
Expand Down
76 changes: 75 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ for what's landed so far.
- **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` is opt-in, for async HTTP
- **Typed throughout** — `mypy --strict` clean on the public API
- *(planned)* `contextvars`-based context propagation — see `CHANGELOG.md`
- **Context propagation, exception capture & the stdlib bridge** — `bind_context()` (`contextvars`-based, no manual passing), `exc_info=` on any `Logger` method (formatted traceback into `meta["stack"]`), `LogQuillHandler` (bridges stdlib `logging` into a `Logger`), and `RateLimitPlugin` — see [Context propagation, exception capture & the stdlib bridge](#context-propagation-exception-capture--the-stdlib-bridge)

## Install

Expand Down Expand Up @@ -780,6 +780,80 @@ the same decorator under a name that reads naturally at each platform's own
handler definition — the flush-before-return behavior is identical across
all three.

## Context propagation, exception capture & the stdlib bridge

`bind_context()` binds request-scoped values for a `with` block — every
`Logger` call underneath it, through any method and any number of function
calls deep, picks them up in `meta` automatically, without threading them
through every function signature by hand:

```python
from logquill import Logger, bind_context

logger = Logger("app")

def process():
return logger.info("processing") # no request_id passed in — picked up from context

with bind_context(request_id="req-42"):
record = process()

assert record["meta"] == {"request_id": "req-42"}
```

It's backed by a `contextvars.ContextVar`, so concurrent asyncio tasks and
threads each see their own bound context; nested `bind_context` blocks
merge, and an explicit call-site value still wins over anything bound this
way — the same override rule `ContextPlugin` uses.

Pass `exc_info=` (an exception instance, `True` for the exception currently
being handled, or an explicit `(type, value, traceback)` tuple — the same
shapes stdlib `logging` accepts) to any `Logger` method to capture a
formatted traceback into `meta["stack"]`:

```python
from logquill import Logger

logger = Logger("app")

try:
1 / 0
except ZeroDivisionError as exc:
record = logger.error("payment failed", exc_info=exc, order_id=42)

assert record["meta"]["order_id"] == 42
assert "ZeroDivisionError" in record["meta"]["stack"]
```

`LogQuillHandler` bridges stdlib `logging` calls — including from
third-party libraries you don't control — into a `Logger`, so they flow
through the same transports and plugins instead of needing every call site
rewritten:

```python
import logging
from logquill import Logger, LogQuillHandler

logger = Logger("app")
logging.getLogger().addHandler(LogQuillHandler(logger))

logging.getLogger("some.library").warning("retrying", extra={"attempt": 2})
# -> flows through `logger`'s transports as a WARN record with meta: {'attempt': 2}
```

`RateLimitPlugin` caps how many records with the same `(logger, level)` (or
a custom `key_func`) pass through per rolling window — for a noisy retry
loop that would otherwise flood a transport:

```python
from logquill import Logger, RateLimitPlugin

logger = Logger("app", plugins=[RateLimitPlugin(max_records=5, per_seconds=60)])

for _ in range(100):
logger.error("connection refused") # only the first 5 per minute ship
```

## Development

```bash
Expand Down
9 changes: 9 additions & 0 deletions logquill/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from logquill.adapters.base import LogQuillAdapter
from logquill.config import load_config, logger_from_env, logger_from_file
from logquill.context import bind_context, current_context
from logquill.exceptions import format_exc_info
from logquill.formatter import Formatter, JSONFormatter
from logquill.handler import LogQuillHandler
from logquill.levels import Level, parse_level
from logquill.logger import Logger
from logquill.plugins.alerting_plugin import AlertingPlugin
Expand All @@ -9,6 +12,7 @@
from logquill.plugins.pagerduty_alert_plugin import PagerDutyAlertPlugin
from logquill.plugins.pii_redact_plugin import PIIRedactPlugin
from logquill.plugins.plugin import FunctionPlugin, Plugin
from logquill.plugins.rate_limit_plugin import RateLimitPlugin
from logquill.plugins.redact_plugin import RedactPlugin
from logquill.plugins.run_plugin import RunPlugin
from logquill.plugins.sampling_plugin import SamplingPlugin
Expand Down Expand Up @@ -69,6 +73,7 @@
"KafkaTransport",
"Level",
"LogQuillAdapter",
"LogQuillHandler",
"LogRecord",
"Logger",
"MongoDBTransport",
Expand All @@ -80,6 +85,7 @@
"PostgresTransport",
"PubSubTransport",
"RabbitMQTransport",
"RateLimitPlugin",
"RedactPlugin",
"RedisTransport",
"RunPlugin",
Expand All @@ -92,6 +98,9 @@
"TamperEvidentPlugin",
"TraceContextPlugin",
"Transport",
"bind_context",
"current_context",
"format_exc_info",
"load_config",
"logger_from_env",
"logger_from_file",
Expand Down
40 changes: 40 additions & 0 deletions logquill/context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
from __future__ import annotations

import contextlib
from contextvars import ContextVar
from typing import Any, Iterator

_current_context: ContextVar[dict[str, Any] | None] = ContextVar("logquill_context", default=None)


def current_context() -> dict[str, Any]:
"""The merged key/value pairs bound by every `bind_context()` block
currently active in this execution context (thread or asyncio task).
"""
return _current_context.get() or {}


@contextlib.contextmanager
def bind_context(**values: Any) -> Iterator[None]:
"""Merge `values` into the request-scoped context for the duration of
this `with` block — every `Logger` call underneath it, through any
method and any number of function calls deep, picks them up in `meta`
automatically, without threading them through every signature by hand:

with bind_context(request_id="abc123"):
handle_request() # any logging in here, or in what it calls,
# gets meta["request_id"] = "abc123" for free

Backed by a `contextvars.ContextVar`, so concurrent asyncio tasks and
threads each see their own bound context — binding in one never leaks
into another. Blocks nest by merging (an inner `bind_context` value
wins over an outer one on key collision, the same way an explicit
call-site `meta` value wins over anything bound here); exiting restores
exactly the context that was active before the block started.
"""
parent = current_context()
token = _current_context.set({**parent, **values})
try:
yield
finally:
_current_context.reset(token)
47 changes: 47 additions & 0 deletions logquill/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from __future__ import annotations

import sys
import traceback
from types import TracebackType
from typing import Literal, Union

# `Literal[True]` rather than `bool`: `False` behaves identically to `None`
# (both mean "nothing to format", handled by the `not exc_info` check below),
# so it isn't a distinct case worth widening the type for — and keeping it
# out lets the branches below narrow cleanly under `mypy --strict`.
ExcInfoArg = Union[
Literal[True],
BaseException,
"tuple[type[BaseException], BaseException, TracebackType | None]",
None,
]


def format_exc_info(exc_info: ExcInfoArg) -> str | None:
"""Render `exc_info` as a formatted traceback string, or `None` if
there's nothing to format. Accepts the same shapes stdlib `logging`
does, so `logger.error("failed", exc_info=e)` reads exactly like the
`logging` module's own `exc_info=` kwarg:

- `True` — format the exception currently being handled (`sys.exc_info()`)
- an exception instance — format it and its own traceback
- an explicit `(type, value, traceback)` tuple
- falsy (`False`/`None`, the default) — nothing to format
"""
if not exc_info:
return None

exc_type: type[BaseException] | None
exc_value: BaseException | None
exc_tb: TracebackType | None

if exc_info is True:
exc_type, exc_value, exc_tb = sys.exc_info()
if exc_type is None:
return None
elif isinstance(exc_info, BaseException):
exc_type, exc_value, exc_tb = type(exc_info), exc_info, exc_info.__traceback__
else:
exc_type, exc_value, exc_tb = exc_info

return "".join(traceback.format_exception(exc_type, exc_value, exc_tb))
99 changes: 99 additions & 0 deletions logquill/handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
from __future__ import annotations

import logging
from typing import TYPE_CHECKING, Any

from logquill.levels import Level

if TYPE_CHECKING:
from logquill.logger import Logger

# Attributes every stdlib `logging.LogRecord` carries for its own bookkeeping
# (formatting, source location, timing) — excluded from the `meta` a bridged
# record produces so `meta` only ever holds what a caller passed as `extra=`,
# the same shape a native LogQuill call would produce.
_STDLIB_RECORD_ATTRS = frozenset(
{
"name",
"msg",
"args",
"levelname",
"levelno",
"pathname",
"filename",
"module",
"exc_info",
"exc_text",
"stack_info",
"lineno",
"funcName",
"created",
"msecs",
"relativeCreated",
"thread",
"threadName",
"processName",
"process",
"taskName",
}
)


def _map_level(levelno: int) -> Level:
"""Stdlib `logging` levels map onto LogQuill's directly except `WARNING`
(30 in both, but named `WARN` here) and `CRITICAL` (50, named `FATAL`
here) — see the cross-language level contract in CLAUDE.md. Anything
that doesn't land exactly on a defined level (a custom intermediate
level, or `NOTSET`) rounds down to the nearest one, the same way stdlib
`logging` itself treats level thresholds as "at least this severe."
"""
if levelno >= logging.CRITICAL:
return Level.FATAL
if levelno >= logging.ERROR:
return Level.ERROR
if levelno >= logging.WARNING:
return Level.WARN
if levelno >= logging.INFO:
return Level.INFO
if levelno >= logging.DEBUG:
return Level.DEBUG
return Level.TRACE


class LogQuillHandler(logging.Handler):
"""Bridges stdlib `logging` calls into a LogQuill `Logger`, so output
from third-party libraries (or code not yet migrated off `logging`)
flows through the same transports and plugin pipeline as LogQuill's own
`.info()`/`.error()`/... calls, instead of needing every call site
rewritten:

handler = LogQuillHandler(logger)
logging.getLogger().addHandler(handler)

logging.getLogger("some.library").warning("retrying", extra={"attempt": 2})
# -> logger.warn("retrying", attempt=2) via the same transports/plugins

Any `extra=` fields passed to the stdlib call land in `meta` exactly
like keyword args to a native LogQuill call would; an attached
`exc_info` is formatted into `meta["stack"]` the same way `Logger`'s own
`exc_info=` kwarg is. Level filtering still goes through the wrapped
`Logger`'s own `set_level()` (via its ordinary `_log` path), on top of
whatever this handler's or the stdlib logger's own level is set to.
"""

def __init__(self, logger: Logger, level: int = logging.NOTSET) -> None:
super().__init__(level)
self._logger = logger

def emit(self, record: logging.LogRecord) -> None:
try:
meta: dict[str, Any] = {
key: value
for key, value in record.__dict__.items()
if key not in _STDLIB_RECORD_ATTRS and key != "message"
}
if record.exc_info:
meta["exc_info"] = record.exc_info
self._logger._log(_map_level(record.levelno), record.getMessage(), meta)
except Exception:
self.handleError(record)
18 changes: 18 additions & 0 deletions logquill/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import logging
from typing import Any

from logquill.context import current_context
from logquill.exceptions import format_exc_info
from logquill.levels import Level, parse_level
from logquill.plugins.context_plugin import ContextPlugin
from logquill.plugins.plugin import FunctionPlugin, MiddlewareFunc, Plugin
Expand Down Expand Up @@ -157,8 +159,18 @@ def _dispatch(self, record: LogRecord) -> None:
def _log(self, level: Level, message: str, meta: dict[str, Any]) -> LogRecord | None:
if level < self._level:
return None

if "exc_info" in meta:
stack = format_exc_info(meta.pop("exc_info"))
if stack is not None:
meta["stack"] = stack

record = create_record(level=level, logger=self.name, message=message, meta=meta)

bound_context = current_context()
if bound_context:
record["meta"] = {**bound_context, **record["meta"]}

parent_span_id = current_span_id()
if parent_span_id is not None:
record["meta"].setdefault("parent_span_id", parent_span_id)
Expand Down Expand Up @@ -200,6 +212,12 @@ def warn(self, message: str, /, **meta: Any) -> LogRecord | None:
return self._log(Level.WARN, message, meta)

def error(self, message: str, /, **meta: Any) -> LogRecord | None:
"""`exc_info=` (an exception instance, `True` for the exception
currently being handled, or an explicit `(type, value, traceback)`
tuple — the same shapes stdlib `logging` accepts) formats a
traceback into `meta["stack"]` and is otherwise not kept in `meta`
as-is, since a raw exception object isn't serializable. Every
`Logger` method accepts it, not just this one."""
return self._log(Level.ERROR, message, meta)

def fatal(self, message: str, /, **meta: Any) -> LogRecord | None:
Expand Down
Loading