A structured, leveled logging framework for Python with pluggable transports.
Sibling to logquill on npm
(logquill-js) — same log record shape, same level names, one mental model
across a Python + Node stack.
Status: pre-release, under active development. The core Logger, level
filtering, transports, and the plugin pipeline are implemented;
non-blocking async dispatch is not yet — see CHANGELOG.md for what's
landed so far.
- Structured by default — every call carries a
metadict, not just a message string - Cross-language record shape — identical JSON shape and level names/weights as
logquillon npm - Pluggable transports —
ConsoleTransport(colorized, stderr for errors),FileTransport(rotation),HTTPTransport(batched); write your own by subclassingTransport - Pluggable formatters —
JSONFormatterout of the box; implementformat(record) -> strfor your own - Plugin pipeline —
ContextPlugin,RedactPlugin,SamplingPluginout of the box; a broken plugin can't crash logging - Zero required runtime dependencies — stdlib only;
aiohttpis opt-in, for async HTTP - Typed throughout —
mypy --strictclean on the public API - (planned) non-blocking async dispatch,
contextvars-based context propagation — seeCHANGELOG.md
pip install logquillfrom logquill import Level, Logger
logger = Logger("app", level=Level.INFO)
record = logger.info("user signed up", user_id=42, plan="pro")
print(record)
# {'timestamp': '2026-08-27T18:04:12.345Z', 'level': 'INFO', 'logger': 'app',
# 'message': 'user signed up', 'meta': {'user_id': 42, 'plan': 'pro'}}
logger.debug("below threshold, dropped") # -> None, filtered by level
logger.set_level("debug")
logger.debug("now visible") # -> a record dictEvery log call returns the record dict (or None if filtered by level) —
{"timestamp": ISO8601, "level": str, "logger": str, "message": str, "meta": dict},
the same shape shared with logquill on npm.
Use JSONFormatter to serialize a record to the canonical JSON line:
from logquill import JSONFormatter
print(JSONFormatter().format(record))
# '{"timestamp":"2026-08-27T18:04:12.345Z","level":"INFO","logger":"app","message":"user signed up","meta":{"user_id":42,"plan":"pro"}}'Attach transports to a Logger to actually write records somewhere. Each
record is dispatched to every attached transport synchronously (non-blocking
dispatch isn't implemented yet):
from logquill import ConsoleTransport, FileTransport, HTTPTransport, Logger
logger = Logger(
"app",
transports=[
ConsoleTransport(), # stdout, ERROR/FATAL to stderr, colorized
FileTransport("app.log", max_bytes=10 * 1024 * 1024, backup_count=5),
HTTPTransport("https://logs.example.com/ingest", batch_size=50),
],
)
logger.info("user signed up", user_id=42, plan="pro")
logger.close() # flushes the file handle and any buffered HTTP batchWrite your own transport by subclassing Transport and implementing
write(formatted, record); format(record) and close() have sensible
defaults. CollectingTransport is a ready-made in-memory transport, handy
in your own tests:
from logquill import CollectingTransport, Logger
sink = CollectingTransport()
logger = Logger("app.test", transports=[sink])
logger.info("hello")
assert sink.records[0]["message"] == "hello"Plugins hook into the pipeline around each log call: before_log(record) can
transform a record or return None to drop it, after_log(record) runs once
it's been dispatched to every transport, and on_error(exc, record) catches
anything a plugin's own hooks raise — a broken plugin can't take down logging.
from logquill import ContextPlugin, Logger, RedactPlugin, SamplingPlugin
logger = Logger("app")
logger.use(ContextPlugin(service="api", env="prod")) # merged into every record's meta
logger.use(RedactPlugin(keys=["password", "token"])) # replaces matching meta values
logger.use(SamplingPlugin(0.1)) # keep ~10% of records that reach this point
logger.info("login attempt", user_id=42, password="hunter2")
# meta: {'service': 'api', 'env': 'prod', 'user_id': 42, 'password': '***'}
# (unless this call was one of the ~90% sampling dropped, in which case it's None)Write your own by subclassing Plugin; override only the hooks you need.
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev,http,hooks]"
pre-commit install
ruff check .
mypy logquill
pytestSee CONTRIBUTING.md for the PR workflow, the Code of Conduct for community standards, and SECURITY.md for how to report a vulnerability.