Unified application logging

lclang.logger provides LoggerHandlerConfig, resolve_logger_config, use_logger_handler, and use_logger. A process owns one asynchronous handler scope. Producer threads enqueue records; one writer formats and writes console and named file sinks. Nested/concurrent scopes are rejected. Logger wrappers select the current process scope, and reject use outside a scope. Initialize after worker creation.

For downstream application patterns and executable examples, read the application logging tutorial. It covers scope ownership, levels, verbose filtering, final failure boundaries, borrowed stdlib loggers and explicit uncaught-error hooks.

Configuration

The default ordinary record is timestamp | level | thread_name(pid) | logger_name | qualified_module_name.py:lineno:func_name | message. The level column is left-aligned with a minimum width of five characters. Caller module names come from the producing Python frame's __name__, including __main__.py for script entry points. The queue snapshots that name before the writer formats the record. Records without caller module metadata retain their original filename. Source lookup does not import modules or read source files.

Every lclang formatter, including LclExceptionFormatter and explicit layouts, displays WARNING as WARN, CRITICAL as CRIT, and NOTSET as UNSET. Marked verbose DEBUG records display TRACE. These are display labels only: original records, numeric levels, configuration names and filtering are unchanged. Application-defined level names retain their spelling and are never truncated. Explicit layouts retain their selected fields and column widths.

Default workflow lifecycle lines retain timestamp, level, process and thread. Business events also retain the application file, line and function, with [task.path] | message headings. Both omit the fixed framework logger name. Explicit format layouts apply to these records unchanged. See the workflow log contract for paths and fields.

See text diagnostics for shared tree layout, TRACE labels, inline code reasons, compact/full tracebacks and external LclExceptionFormatter.

Python objects and mappings share the level, format, timezone, traceback_style, console, file, takeover_loggers, and capture_warnings fields. No configuration files are read by this package. Applications load .lclcfg files with lclang.config, then call await resolve_logger_config(frame, verbose=False) to materialize logger.* from their ordinary Frame. CLI/Workflow use the same resolver after their -c/-o layering, before entering the handler scope.

timezone accepts exactly "local" (the default) or "utc". It applies to %(asctime)s in every console and file sink. Timestamps retain microseconds: local time includes the server operating system's numeric offset, for example 2026-10-08T15:30:00.123456+08:00; UTC ends in Z, for example 2026-10-08T07:30:00.123456Z. Local zero offset remains +00:00. Conversion uses the record's creation instant and the local rules at that instant, including daylight-saving transitions, rather than the writer's current time. Formats without %(asctime)s do not show a timestamp.

Set LoggerHandlerConfig(timezone="utc"), pass {"timezone": "utc"} to the handler scope, declare logger.timezone: "utc" in LCL, or use CLI -o logger.timezone utc to retain the previous UTC-only behavior. The default has changed to local time. Non-text values raise LclValidationError; other strings, including "UTC" and named zones, raise LclValidationError before sink initialization. Verbose mode preserves the timezone. Segment filenames and aligned rotation continue to use UTC; CLI execution-time variables retain their existing meaning.

file is a mapping of sink names. file.default supplies missing leaf fields and never creates a sink. Explicit sink fields always take precedence over the template, including False and empty collections. Thus setting logger.file.default.enabled: False disables only sinks without an explicit enabled: True. Override one sink directly to disable that exception: -o logger.file.audit.enabled "LCL[False]". A disabled sink does no file I/O.

Files have independent enabled, directory, filename, level, encoding, flush_interval, logger_names, and rotation settings. Rotation supports none, size, time, and size_or_time; intervals use positive integer s/m/h/d units. Aligned rotation supports UTC 1h and 1d. Source names match themselves and dot-separated descendants. Empty source lists match all.

The console defaults to INFO on stderr. Files default to INFO, UTF-8 with backslashreplace, one-second flushing, and no rotation. An enabled file needs a directory. Missing names become lclang.<sink>.{pid}.log. Templates support {pid} and {process}. Global level defaults to NOTSET. Warnings capture is opt-in. Verbose CLI execution lowers enabled output thresholds to DEBUG, without enabling disabled sinks or removing source filters. Disabled outputs retain their configured thresholds. Invalid fields include their complete logger path; CLI diagnostics also identify the defining source and position when available, or the owning configuration layer otherwise.

Public Python types

All of the following can be imported directly from lclang.logger:

Type Role
Logger Wrapper returned by await use_logger(...); use it to annotate application logging arguments.
LoggerHandlerConfig Immutable declaration accepted by use_logger_handler.
LoggerRuntime Runtime yielded by the handler scope; read metrics for diagnostics.
LclExceptionFormatter Opt-in standard formatter with protected LCL trees and compact or full Python tracebacks.
install_exception_hooks Explicit stderr reporting for uncaught LCL exceptions in the main thread, worker threads and an optional selected loop.
ExceptionHookInstallation Installation handle with idempotent restore() that preserves later handler replacements.
ConsoleConfig, FileConfig, RotationConfig Resolved output policies returned by resolved_console() and resolved_files().
RuntimeMetrics, SinkMetrics Detached aggregate and per-sink diagnostic snapshots.

Obtain loggers and runtimes through the scope APIs. Resolved policy types describe validated results; declare console, file and rotation settings with mappings in LoggerHandlerConfig.

Configuration without CLI

resolve_logger_config evaluates only the logger subtree and its dependencies, using the supplied Frame's hierarchy, overrides and cached snapshots. Missing fields use the ordinary handler defaults; an absent logger namespace returns LoggerHandlerConfig() settings. If present, logger must be a scoped FrameProxy, not a scalar or a nested Python mapping. Invalid namespace or field types raise LclValidationError; invalid values and failing leaf expressions raise LclValidationError. Field validation includes source metadata when available, and leaf evaluation errors include the logger path and retain their cause.

Resolution does not enter a handler scope, initialize sinks, modify bindings, or close the Frame. Application-supplied callables evaluated by LCL retain their normal effects. verbose=True lowers the global and enabled output thresholds to DEBUG while preserving lower thresholds, disabled outputs and source filters. No CLI invocation variables are injected; supply application inputs explicitly.

service.lclcfg:

__LCL_VERSION__: 1
logger.timezone: "utc"
logger.console.enabled: False
logger.file.default.directory: log_directory
logger.file.service.filename: f"{service}.log"
logger.file.service.level: "INFO"

Run the following program beside the file:

import asyncio
from pathlib import Path

from lclang.config import load_config
from lclang.logger import Logger, resolve_logger_config, use_logger, use_logger_handler


async def main() -> None:
    directory = str(Path.cwd())
    path = Path(directory) / "service.lclcfg"
    loaded = await load_config(path)
    async with loaded.to_frame(
        preset={"service": "catalog", "log_directory": directory}
    ) as frame:
        config = await resolve_logger_config(frame)
        assert config.timezone == "utc"
        assert config.resolved_files()["service"].filename == "catalog.log"
        async with use_logger_handler(config) as runtime:
            logger: Logger = await use_logger("catalog")
            logger.info("service ready")
        metrics = runtime.metrics
        assert metrics.records_written == 1
        segment = metrics.sinks["file.service"].path
        assert segment is not None
        assert "service ready" in segment.read_text(encoding="utf-8")
    print(metrics.records_written, "service ready")


asyncio.run(main())

The program prints:

1 service ready

The application owns service and log_directory; the file derives the sink's filename and inherits its directory from file.default. Resolving the Frame produces the same immutable configuration used by CLI. The handler scope drains the queued record before the assertions read its permanent segment, then the Frame and temporary directory close.

Verbose execution diagnostics

use_verbose(logger, *, enabled=True) selects a borrowed standard-library logging.Logger or lclang.logger.Logger for execution diagnostics. It is a synchronous context manager that can span await. It does not configure handlers, change logging levels, or own tasks. Child tasks inherit the selected context; callers remain responsible for awaiting them. Exit restores the prior context, including after cancellation. enabled=False or logger=None silences verbose diagnostics inside the scope, even when an outer scope is active. enabled must be Boolean. Diagnostics remain DEBUG records and obey the selected logger's levels and filters. Disabled tracing skips trace-only value rendering.

Messages describe actual parsing, evaluation and lookup operations. Complex events use indented details; cache and fallback reads use short lines. Each new successful root request adds its actual dependency tree once. Cached and shared results remain leaves. Record extras include lclang_verbose, lclang_stage, lclang_evaluation_id, lclang_failure_id, an immutable lclang_context of text field pairs and lclang_tree for detached tree snapshots. Completed-expression metadata retains the expression type, canonical text and source location without repeating them in each event line. Stages are parse, evaluate, lookup and command. Evaluation and failure identifiers are None when that event has no corresponding evaluation or failure. Handlers can filter these fields without parsing the message. Values are bounded and masked before they enter a record.

The first observed failure has a short reason and execution context, including the requesting Frame, binding owner and lookup path when available. Later propagation and committed failure caches are summarized once when the root request ends. The summary lists the cached binding names; detached record context retains the individual propagation relations and cache owners. Recovered failures are described as observed failures, without claiming that the root failed. Shared returns and cached failures remain separate short references. A cached failure was not evaluated again. Its identifier survives another verbose scope, diagnostic copies and exception-group derivation. A new failure object receives a new identifier. If tracing first observes an old cache entry, the record says so; it does not reconstruct earlier execution.

Complete source excerpts, read-value snapshots, causes and Python tracebacks belong to the exception report. LclError.verbose_failure_id connects that report to the execution records. Error rendering includes the identifier when present; untraced failures keep their ordinary display. A caught exception still has a useful first verbose record even if the application does not log the exception.

import asyncio
import io
import logging

from lclang.error import LclError
from lclang.lang import define_frame, define_module
from lclang.logger import use_verbose


async def main() -> None:
    output = io.StringIO()
    logger = logging.Logger("application.verbose", logging.DEBUG)
    handler = logging.StreamHandler(output)
    logger.addHandler(handler)
    identity = None
    module = define_module("pricing", {"total": "amount / divisor"})
    try:
        async with define_frame(module, preset={"amount": 39, "divisor": 0}) as frame:
            with use_verbose(logger, enabled=False):
                assert await frame.get("amount") == 39
            assert output.getvalue() == ""
            with use_verbose(logger, enabled=True):
                for _ in range(2):
                    try:
                        await frame.get("total")
                    except LclError as error:
                        if identity is None:
                            identity = error.verbose_failure_id
                        assert identity is not None
                        assert error.verbose_failure_id == identity
                        assert f"(failure {identity})" in str(error)
        text = output.getvalue()
        assert "Reason:" not in text
        assert "[LCL131421: division by zero]" in text
        assert "cached failure" in text and "Cause:" not in text
        assert identity is not None and identity in text
    finally:
        logger.removeHandler(handler)
        handler.close()


asyncio.run(main())

The disabled scope reads the external amount without producing a verbose record. The first total lookup reads amount and divisor, then fails during division. Its short failure record includes the request and lookup context. The second lookup returns the same failure from the Frame cache and references its identifier. The application owns the handler and captures the DEBUG records in memory. Neither failure is logged as an exception here; its complete diagnostic remains available through str(error) and retains the same identifier.

Records and ownership

use_logger(name=None, prefix="", emit_level=0) selects a stdlib logger; None selects root. Prefixes are formatter-only and appear on the first line. emit_level skips additional wrapper callers through stdlib stacklevel. Logger.log, debug, info, warning, error, critical and exception accept the keyword-only compact: bool = False parameter. For example, logger.info("Task started", compact=True) selects timestamp | level | thread_name(pid) | message for that record's default layout. Other calls through the same logger keep their usual layout. An explicit configured format remains authoritative. Prefixes, arguments, numeric severity, filters and exception trees work in either layout; the original record retains its logger name and caller location. A non-Boolean compact raises LclValidationError with code LCL612141 through shared logger flag validation. Messages, arguments and exception formatting run in the writer. Queue records are shallow copies: do not mutate referenced arguments after logging.

The root and explicitly listed takeover loggers are restored on exit. Existing handlers are detached, never closed or reformatted. Default takeover names are uvicorn, uvicorn.error and uvicorn.access. Other logger-specific levels and handlers retain stdlib semantics. Streams are borrowed and never closed.

Permanent segments and shutdown

Each file is exclusively created at its permanent path, with UTC microseconds and a process-wide increasing sequence appended to its base name. The first line identifies that path. Rollover creates the next segment first, then ends the old segment with its successor path and closes it. Closed segments are never changed, renamed, compressed, or deleted. Header bytes count toward size; the successor footer may exceed the limit. Oversized records are never split. Empty segments still rotate on time. Size rotation preserves time deadlines.

Scope entry fails if an enabled output cannot initialize. Normal, exceptional, and cancelled exits stop admission, drain, flush, close, asynchronously join the writer, and restore logging state. There is no drain timeout. Runtime sink failures are counted and reported minimally to sys.stderr; other sinks continue. Failed files recover into new segments without replaying possibly partial writes. Hardware failures may leave gaps. Forced process termination and permanently blocked I/O cannot guarantee delivery.

runtime.metrics supplies immutable aggregate and per-sink snapshots with records_enqueued, records_written, writer_errors, rollover_count and paths. Aggregate records_written counts a record once if any sink accepted its write; metadata is excluded. Successful writes do not imply fsync durability.

Service entry points

Initialize in the actual worker, before Uvicorn emits its startup messages. Wrapping only FastAPI's lifespan starts too late and closes too early for the server's own logging. Request tasks and background tasks can call use_logger directly because admission is process-wide. Do not nest handler scopes in routes or in a Workflow invoked by the service.

On Windows or Linux, save this as service.py and run python service.py. FastAPI and Uvicorn are dependencies of this example application, not lclang. The serve boundary can also surround an existing configured Server instance.

import asyncio

import uvicorn
from fastapi import FastAPI

from lclang.logger import LoggerHandlerConfig, use_logger, use_logger_handler

app = FastAPI()


@app.get("/")
async def index():
    logger = await use_logger(name=__name__, prefix="[HTTP]")
    logger.info("request received")
    return {"ok": True}


async def serve(server, config, *, sockets=None):
    async with use_logger_handler(config):
        await server.serve(sockets=sockets)


if __name__ == "__main__":
    server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=8000, log_config=None))
    config = LoggerHandlerConfig(file={"service": {"directory": "./logs"}})
    asyncio.run(serve(server, config))

log_config=None lets the handler scope own logging installation. Scope exit waits for queued server and application records, including the final server shutdown message. Configure process spawning outside this entry point when multiple independent Windows workers are required; each child calls serve.

For Linux Gunicorn, save the following as logging_worker.py alongside service.py, install the application's gunicorn and uvicorn-worker dependencies, and run:

gunicorn service:app --workers 4 --worker-class logging_worker.LoggingWorker --access-logfile -
import os
import signal

from uvicorn_worker import UvicornWorker

from lclang.logger import LoggerHandlerConfig, use_logger_handler


class LoggingWorker(UvicornWorker):
    def init_signals(self):
        super().init_signals()
        signal.signal(signal.SIGTERM, self.handle_exit)

    async def _serve(self):
        config = LoggerHandlerConfig(
            file={"service": {"directory": os.environ.get("LOGGER_DIRECTORY", "./logs")}}
        )
        async with use_logger_handler(config):
            await super()._serve()

The subclass enters the scope inside the worker's asynchronous service method, after fork and worker initialization. The master never starts a logger writer; Gunicorn's own handlers remain its responsibility. LOGGER_DIRECTORY optionally selects the file directory. Every worker gets independent permanent paths. Keep Gunicorn's graceful SIGTERM handler installed before entering Uvicorn. Uvicorn restores the previous handler and replays SIGTERM after server shutdown; the worker handler marks it no longer alive and returns so the outer logging scope can drain and close. Leaving SIGTERM at its default action terminates the process before that cleanup and can leave buffered log files with only a header. The adapter depends on uvicorn-worker's _serve hook; integration tests target uvicorn-worker==0.4.0 and uvicorn==0.52.4. Recheck this hook when upgrading the server adapter (worker source, Uvicorn server).

The quality suite runs actual loopback HTTP, lifespan, request, background-task, and shutdown checks with FastAPI/Uvicorn. Its Linux job additionally starts and terminates Gunicorn using the exact worker example above. Server packages appear only in the development integration dependency group.

Performance measurement

Run python -m scripts.logger_benchmark --records 1000 --threads 1 10 100 in a development checkout. It reports call p50/p95/p99 and producer records/second for console, file and combined output. Console targets the null device to avoid terminal-rendering variability. Startup, thread creation, and queue drain are outside the producer measurement. This measures admission capacity, not sustained disk throughput; an unbounded queue can grow when producers outpace the writer. The checked-in measurement sample records the host and Python version and is not a portable performance guarantee.

See errors and diagnostic codes for exception fields, specific codes, cause chains, and executable troubleshooting examples. Ordinary library validation uses LclValidationError; native callback and operation failures retain their original exception in __cause__.

The exception guide describes each failure family, grouped execution and cleanup failures, actual diagnostic output, and application handling with except* and native control signals.