Add the context to every log record#
A request id belongs on every line a request logs, and passing it to each call is the drilling this library removes.
The usual answers are a logging.Filter over a private ContextVar, structlog’s contextvars integration, or a whole tracing dependency pulled in for one field.
A provider is already that ContextVar, so the filter is ten lines and reads the same public API everything else reads.
import logging
from nodrill import provider, use
class ScopeFilter(logging.Filter):
"""Put one string-named provider's fields on every record, under a flat prefix."""
def __init__(self, scope: str, *fields: str) -> None:
super().__init__()
self.scope = scope
self.fields = fields
def filter(self, record: logging.LogRecord) -> bool:
namespace = use(self.scope, default=None)
for field in self.fields:
setattr(record, f"nodrill_{self.scope}_{field}", getattr(namespace, field, "-"))
return True
def main() -> None:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("%(nodrill_audit_request_id)s %(message)s"))
handler.addFilter(ScopeFilter("audit", "request_id", "reason"))
logging.basicConfig(level=logging.INFO, handlers=[handler])
logging.getLogger("shop").info("no scope is open")
with provider("audit", request_id="01JB2", reason="bulk import"):
logging.getLogger("shop.orders").info("one line about an order")
if __name__ == "__main__":
main()
Output:
- no scope is open
01JB2 one line about an order
The four rules the recipe encodes#
The filter goes on the handler, not on the root logger.
A logger’s filters run only for records logged through that logger, and a record propagating up from shop.orders never meets the root logger’s filters.
A handler’s filters run for every record it emits, which is what “on every line” means.
The fields are declared rather than discovered.
A %-style formatter naming an attribute the record does not carry fails at emit time, and logging.Handler.handleError() swallows it, so the line is dropped and a traceback goes to stderr instead of to the log.
Listing the fields in the constructor is what lets the filter fill each one with a placeholder when no scope is open, as the first output line shows.
A structured handler that reads whatever it finds can drop the list and copy vars(namespace) instead.
Names are flattened with an underscore and prefixed.
%(nodrill_audit_request_id)s works in any stdlib formatter, while a dotted name reads as an attribute access that is not one, and an unprefixed request_id can collide with a LogRecord attribute or with another library’s field.
One filter per named provider.
Two providers both claiming request_id would otherwise race for one attribute, and which one wins would depend on iteration order.
Adding a second ScopeFilter("http", "path") is clearer than one filter that walks active().
Class-keyed providers are left out on purpose, since the repr of a RequestScope is not a set of fields, and a class that wants to contribute can expose one through a plain accessor the filter calls.
This is a recipe and not a shipped class, because the questions above have per-project answers and a name in the public API cannot be withdrawn once it has one.
See also
See the context in a traceback for the same values on a failure, where no logging is configured at all.
Providers and lookups for use() and the default= that keeps the filter working outside every scope.