Skip to main content

Structured logging for microservices — built on Python's standard library, not around it.

Project description

boringlog

Structured logging for microservices — built on Python's standard library, not around it.

Logging should be boring. No new runtime, no parallel logger object to learn, no magic. boringlog is a thin layer over the standard library logging that gives you sane defaults in one call, structured fields as keyword arguments, and shared context on every line — then gets out of your way.

from boringlog import configure_logging, get_logger

configure_logging(global_context={"source": "api", "worker_id": "w-1"})
log = get_logger(__name__)
log.info("server started", port=8000)
10:35:36 [INFO] [__main__]: server started | port: 8000 | source: api | worker_id: w-1

Why "boring"?

Because boring is a feature. boringlog doesn't replace logging the way some libraries do — it configures it. That means:

  • Nothing new to learn. It's standard logging underneath. Your knowledge, your handlers, your LOG_LEVEL all still apply.
  • Third-party logs come along for free. Because everything flows through the standard logging machinery, logs from httpx, sqlalchemy, uvicorn, etc. go through the same handlers and carry your global context too.
  • Zero dependencies. It's a single, dependency-free module (~350 lines) you can read in one sitting and vendor into any project.

If you want a feature-rich, opinionated logging framework, reach for loguru or structlog. If you want your standard library logging to just be configured well and behave predictably across all your services, that's this.

Requirements

Python 3.10+ (uses PEP 604 X | Y type unions). No third-party dependencies.

Installation

boringlog is a single, dependency-free module.

pip install boringlog

In requirements.txt, pinned to a version (recommended):

boringlog==0.1.0

Or install from source — latest main, or pinned to a tag:

pip install git+https://github.com/kizeev/boringlog.git
pip install git+https://github.com/kizeev/boringlog.git@v0.1.0

Or just copy src/boringlog/ into your project — it has no dependencies.

Quick start

Call configure_logging() once, as early as possible in your process entrypoint. Then grab a logger anywhere with get_logger().

from uuid import uuid4
from boringlog import configure_logging, get_logger

configure_logging(
    main_handlers=["console", "json_file"],
    logger_levels={"httpx": "CRITICAL"},
    global_context={"source": "extractor", "worker_id": str(uuid4())},
)

log = get_logger(__name__)
log.info("worker started", port=8000, mode="batch")

Console output:

10:35:36 [INFO] [__main__]: worker started | port: 8000 | mode: batch | source: extractor | worker_id: 3f2a...

JSON output (one object per line, written to logs.jsonl by default):

{"timestamp": "2026-06-17T10:35:36.561665+00:00", "level": "INFO", "logger": "__main__", "message": "worker started", "port": 8000, "mode": "batch", "source": "extractor", "worker_id": "3f2a..."}

Logging API

get_logger() returns a wrapper with the usual levels. Any keyword argument becomes a structured field:

log = get_logger(__name__)

log.debug("cache lookup", key="user:42", hit=False)
log.info("request handled", path="/items", status=200, ms=12.4)
log.warning("retrying", attempt=3, backoff_s=2)
log.error("upstream failed", url="https://...", status=503)

Exceptions

Use .exception() inside an except block to attach a traceback, or pass exc_info=True to .error() / .critical():

try:
    risky()
except ValueError:
    log.exception("failed to parse payload", payload_id=123)

Context

There are three ways to attach context, and they answer different questions.

Global context — set once in configure_logging(global_context=...). These fields land on every log line in the process, from any logger, including third-party libraries. This is where process-wide identifiers belong: service name, worker id, deployment region, build version.

configure_logging(global_context={"source": "api", "version": "1.4.2"})

Per-logger context — bound to a single logger via get_logger(name, context=...). These fields are merged into every call made through that wrapper. Use it for a component or subsystem identifier.

log = get_logger("api.billing", context={"component": "billing"})
log.info("charge created", amount=1999)  # carries component=billing

You can also derive a child wrapper with extra bound fields via .bind():

req_log = log.bind(request_id="abc-123")
req_log.info("validating")   # carries component=billing and request_id=abc-123

Scope-local context — set with log_context(...) (or bind_context()), scoped to the current job, request, or task. Every log emitted while the block is active carries the fields — from any logger, including nested functions with their own loggers and third-party libraries — without threading a logger through your call stack. It is safe under concurrency: each asyncio task (and each thread) gets its own copy, so values never leak between jobs.

from boringlog import log_context, get_logger

log = get_logger(__name__)

def process_job(job):
    with log_context(job_id=job.id):
        log.info("started")     # carries job_id
        handle(job)             # every log inside, from anywhere, carries job_id
    # outside the block, job_id is gone

For frameworks where a context manager doesn't fit (e.g. middleware), use the token form directly:

from boringlog import bind_context, reset_context

token = bind_context(request_id=req.id, user_id=req.user)
try:
    handle(req)
finally:
    reset_context(token)

Generators caveat. Generators share their caller's context, so a log_context(...) block that spans a yield stays active in the consumer between iterations — scope-local fields can "leak" onto the consumer's logs. If that matters (e.g. a per-parser marker leaking onto a worker's logs), decorate the generator function with @isolate_generator: it advances the generator in its own context copy, so context set inside stays invisible to the consumer, while context the consumer already had (e.g. job_id) remains visible inside. The consumer keeps iterating normally — no special code.

from boringlog import isolate_generator, log_context

@isolate_generator
def start_extracting(self):
    with log_context(parser_marker=self.parser.path):
        ...
        yield from self.extract(request)   # parser_marker won't leak to the consumer

When the whole generator should run under the same fields, generator_context(**fields) collapses the decorator and the with into one line. Values may be static or callables resolved against the call arguments (e.g. to read self):

from boringlog import generator_context

@generator_context(parser_marker=lambda self: self.parser.path)
def start_extracting(self):
    ...                                    # no inner `with`; body un-indented
    yield from self.extract(request)

Precedence, from strongest to weakest: explicit per-call keyword arguments > per-logger bound context (.bind() / get_logger(context=...)) > scope-local context (log_context) > global context. A stronger field is never overwritten by a weaker one.

Configuration reference

configure_logging(...) accepts:

Argument Type Default Description
main_handlers list[str] ["console"] Built-in handlers to enable: "console" (stdout, human format) and/or "json_file".
extra_handlers dict[str, dict] {} Additional dictConfig handler definitions to merge in.
logger_levels dict[str, str] {} Per-logger level overrides, e.g. {"httpx": "CRITICAL"}.
json_log_path str "logs.jsonl" Path for the JSON file handler (used when json_file is enabled).
global_context dict[str, Any] None Fields injected into every log record across the process.
force bool False Reconfigure even if logging was already configured.

The root log level is read from the LOG_LEVEL environment variable (default INFO). Handlers themselves accept DEBUG, so changing LOG_LEVEL is enough to widen or narrow output.

LOG_LEVEL=DEBUG python -m myservice

Custom handlers

extra_handlers takes standard dictConfig handler dicts. Reference the built-in "json" or "human" formatters by name:

configure_logging(
    main_handlers=["console"],
    extra_handlers={
        "errors_file": {
            "class": "logging.FileHandler",
            "filename": "errors.log",
            "level": "ERROR",
            "formatter": "json",
        },
    },
)

Real-world example: a queue worker

A worker pulls jobs off a queue (RabbitMQ here), runs an extractor with one or more parsers per job, and ships logs to the console, a JSON file, and Graylog — all carrying the right identifiers without threading them through every call.

Configure once at startup. source/worker_id are global, third-party noise is turned down, and a GELF handler is added with no extra code (the service depends on graypy; boringlog stays dependency-free):

from uuid import uuid4
from boringlog import configure_logging, get_logger, log_context

configure_logging(
    main_handlers=["console", "json_file"],
    extra_handlers={
        "graylog": {
            "level": "INFO",
            "class": "graypy.GELFTCPHandler",
            "host": "127.0.0.1",
            "port": 12201,
            "localname": SERVICE_NAME,
        },
    },
    logger_levels={"httpx": "CRITICAL", "pika": "CRITICAL"},
    global_context={"source": SERVICE_NAME, "worker_id": str(uuid4())},
)

logger = get_logger("worker")

Scope job_id to the whole job — every log during processing carries it, including the extractor's and the parser's, and _job_id reaches Graylog as a searchable field:

def process_job(self, body):
    data = json.loads(body)
    job_id = data["job_id"]

    with log_context(job_id=job_id):
        logger.info("Processing job", parser=data["parser_path"])
        extractor = Extractor(job_id=job_id, parser_path=data["parser_path"])
        for msg in extractor.start_extracting():
            self.send_result_to_queue(job_id, msg)
        logger.info("Job done")

Inside the extractor, scope parser_marker to the parser run with one decorator. It binds the marker for the whole generator and keeps it from leaking onto the worker's own logs between yields:

from boringlog import get_logger, generator_context

logger = get_logger("worker.extractor")

class Extractor:
    @generator_context(parser_marker=lambda self: self.parser.path)
    def start_extracting(self):
        logger.info("Run extractor")          # carries job_id + parser_marker
        for request in self.parser.start():
            yield from self.extract(request)   # parser_marker won't leak to the worker
        logger.info("Finish extractor")

The result, per layer: every line has source and worker_id; everything within a job has job_id; everything emitted while a parser runs (extractor logs, the parser's own logs, even a third-party library it calls) additionally has parser_marker. None of it is passed by hand.

Notes & behavior

  • Reserved field names are safe. If you pass a field whose name collides with a LogRecord attribute (module, name, message, msg, args, …), it is automatically namespaced with a ctx_ prefix instead of raising. So log.info("x", module="parser") logs ctx_module: parser.
  • Timestamps. JSON output uses timezone-aware UTC ISO-8601 (...T10:35:36.561665+00:00) for safe cross-service correlation. The console formatter shows local HH:MM:SS for readability.
  • Import order doesn't matter. Existing loggers created before configure_logging() keep working — they are not disabled.
  • Non-JSON-serializable values (datetime, date, set, UUID, …) are encoded gracefully, with a string fallback so a log call never fails to serialize.

License

MIT. See LICENSE.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

boringlog-0.1.0.tar.gz (15.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

boringlog-0.1.0-py3-none-any.whl (13.5 kB view details)

Uploaded Python 3

File details

Details for the file boringlog-0.1.0.tar.gz.

File metadata

  • Download URL: boringlog-0.1.0.tar.gz
  • Upload date:
  • Size: 15.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for boringlog-0.1.0.tar.gz
Algorithm Hash digest
SHA256 175226b3bf2d7c54bf516a7842eed0aa6069a8612da8c437efd0dd0e1d2b774a
MD5 f1f7925b2dc091535af748c45a86c3d7
BLAKE2b-256 af809efaa406641097152fd6f49de4cf22364ebe12ac1d89dc62d27bc032db06

See more details on using hashes here.

Provenance

The following attestation bundles were made for boringlog-0.1.0.tar.gz:

Publisher: publish.yml on kizeev/boringlog

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file boringlog-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: boringlog-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 13.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for boringlog-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dc0a057a849463d74a8255a561fc86d57b87ac28b322b561ae2c6d8a3b1578e1
MD5 92f7fa7cf121a128737beb0a82a6b030
BLAKE2b-256 b6c2e1560a12f7cf64e3f7332712d61681f42ef21bda647e42eb2389151583fe

See more details on using hashes here.

Provenance

The following attestation bundles were made for boringlog-0.1.0-py3-none-any.whl:

Publisher: publish.yml on kizeev/boringlog

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page