Application logging

This chapter is for developers embedding lclang in a Python application. It explains who owns logging, how to enable execution diagnostics for one operation, and where to report failures so users receive one complete explanation.

lclang.logger also works without LCL evaluation. Its handler scope owns a queue writer and output sinks; business code uses named loggers inside that scope.

The output blocks below are captured from the exact examples in isolated processes. File examples print their event records after shutdown and omit the segment header containing the generated path. Displayed fixture paths use the filename alone. <string> identifies source supplied inline; saving the Python program in a file gives its traceback that file's location. Evaluation/failure identifiers and task names can differ in a longer-running application.

Choose the integration at the application boundary

Application Recommended entry point
Standalone Python program Own one use_logger_handler(config) scope and obtain named loggers with await use_logger(...).
lclang CLI command Use context.logger; CLI owns configuration, logging and the final failure report.
Workflow action Use context.logger and context.log_event(...); retain the workflow's task and cleanup reporting.
Application with an existing logging system Borrow a logging.Logger through use_verbose; install LclExceptionFormatter on an application-owned handler when desired.
Reusable application library Accept a logger or use an application-selected stdlib source name; leave handlers and uncaught-error hooks to the application.

Keep handler installation in the process entry point. One process can have only one active lclang handler scope, including across concurrent tasks and threads. Start it after worker creation. All work that can log, including resource cleanup, must finish before the scope closes. A service keeps the scope around its server run; a finite program keeps it around that run's business operations.

CLI and workflow entry points already manage their logger lifecycle. A command, route or task should not create another handler scope. See the service entry points for server and worker examples, and the CLI tutorial for command ownership.

Own the writer once and pass named loggers to business code

Use stable source names such as example.billing. Put a request identifier in the message, a logger prefix or application record metadata; avoid creating a new logger name for every request. Stdlib loggers and their names persist in the process registry.

This example runs two operations concurrently under one writer. The temporary directory isolates its file output and closes after the writer has drained.

import asyncio
from tempfile import TemporaryDirectory

from lclang.logger import Logger, LoggerHandlerConfig, use_logger, use_logger_handler


async def process_order(logger: Logger, quantity: int) -> int:
    total = quantity * 6
    logger.info("Processed order: quantity=%d total=%d", quantity, total)
    return total


async def main() -> None:
    with TemporaryDirectory() as directory:
        config = LoggerHandlerConfig(
            timezone="utc",
            format="%(levelname)s | %(name)s | %(message)s",
            console={"enabled": False},
            file={"application": {"directory": directory, "level": "INFO"}},
        )
        async with use_logger_handler(config) as runtime:
            first = await use_logger("example.billing", prefix="[order=A]")
            second = await use_logger("example.billing", prefix="[order=B]")
            totals = await asyncio.gather(
                process_order(first, 2), process_order(second, 3)
            )
            assert totals == [12, 18]
        path = runtime.metrics.sinks["file.application"].path
        assert path is not None
        text = path.read_text(encoding="utf-8")
        assert "[order=A] Processed order: quantity=2 total=12" in text
        assert "[order=B] Processed order: quantity=3 total=18" in text
        assert runtime.metrics.records_written == 2
        print("\n".join(text.splitlines()[1:]))


asyncio.run(main())

The program prints the file's event records after shutdown. The segment header contains its generated path and is not included in this display:

INFO | example.billing | [order=A] Processed order: quantity=2 total=12
INFO | example.billing | [order=B] Processed order: quantity=3 total=18

process_order receives a logger and owns no handler. Both operations use the same source name with different prefixes. gather finishes both producers before scope exit drains the queue. Read files and final metrics after that exit; an enqueued record may not have been written yet.

Use logging templates and arguments, as above, to defer message formatting. Queue records are shallow snapshots, so finish constructing arguments before logging and do not mutate referenced objects afterwards. For expensive business summaries, check logger.isEnabledFor(logging.DEBUG) before constructing them. That checks producer admission; a sink can still reject the resulting record.

Use compact records for task boundaries

Pass compact=True to a downstream logger method when a task boundary needs only the message and the shared timestamp, level and thread/process prefix. Ordinary calls on the same logger keep the full default layout.

import asyncio
import io

from lclang.logger import Logger, use_logger, use_logger_handler


async def main() -> None:
    output = io.StringIO()
    async with use_logger_handler({"console": {"stream": output}}):
        logger: Logger = await use_logger("catalog.batch")
        logger.info("Task started", compact=True)
        logger.warning("Copied %d rows", 3)
        logger.info("Task finished", compact=True)
    lines = output.getvalue().splitlines()
    assert [line.split(" | ")[-1] for line in lines] == [
        "Task started", "Copied 3 rows", "Task finished"
    ]
    assert len(lines[0].split(" | ")) == len(lines[2].split(" | ")) == 4
    assert " | catalog.batch | " in lines[1]
    assert ".py:" in lines[1] and " | WARN  | " in lines[1]


asyncio.run(main())

The first and last lines omit the logger name and caller location. The warning between them retains both, and formats its argument in the writer. The scope drains all three records before the assertions run. compact=False is the default on log, debug, info, warning, error, critical and exception. The flag changes the default layout for one record; an explicitly configured format still controls its selected fields. Exception records keep their full diagnostic tree in either layout.

Configure levels and outputs before starting business work

LoggerHandlerConfig accepts Python declarations or mappings. If the application loads .lclcfg, first call await resolve_logger_config(frame, verbose=verbose) on its configuration Frame, then enter the handler scope with the result. See configuration without CLI for a complete file-backed example. The resolver does not open a handler scope or close the supplied Frame.

Choose console and file thresholds separately. A useful application policy is INFO on stderr and DEBUG in a diagnostic file. Set the producer/global threshold to DEBUG when collecting verbose records, and enable DEBUG in the intended sink. An explicitly configured named logger can also reject records before they reach the root handler. List loggers in takeover_loggers only when the application intends to replace their handlers and levels for the scope; other named loggers retain their stdlib settings.

use_verbose emits DEBUG records but does not lower thresholds. The framework formatter displays their level as TRACE without changing the original record. resolve_logger_config(..., verbose=True) lowers the global and enabled sink thresholds to DEBUG. Disabled sinks stay disabled, and source filters remain in effect. logger_names on a file sink selects a source and its dot-separated descendants; it does not set the producer's level.

Keep console output on stderr when stdout carries a result or another program's input. The default format uses a five-character level column, thread_name(pid) and qualified_module_name.py:lineno:func_name; verbose and workflow lifecycle records omit the source column. All lclang formatters display WARNING as WARN, CRITICAL as CRIT, NOTSET as UNSET and marked verbose records as TRACE. Configuration names and numeric severities stay unchanged. Use this default or choose an explicit application format. Explicit formats are respected. timezone="utc" changes displayed timestamps to UTC; the default uses local time with a numeric offset. It has no effect when the chosen format omits %(asctime)s.

Named files use permanent segment paths. Use runtime.metrics.sinks to find those paths; the configured filename is the base name, not a promise that the writer will create exactly that path. Rotation creates new segments and retains old ones. The application or its deployment tooling owns retention.

Trace one operation and report its final failure once

Let business functions propagate failures with their existing diagnostics. The application boundary decides whether to recover, return an error response or terminate. Log one complete exception there, after owned resources have closed. If execution and cleanup both fail, the resulting LCL group retains both members. Logging before cleanup can miss the final failure set.

This boundary handles a failed calculation and records it once. The Boolean verbose argument enables the same business code to run with or without tracing.

import asyncio
from tempfile import TemporaryDirectory

from lclang.error import LclError
from lclang.lang import define_frame, define_module
from lclang.logger import Logger, LoggerHandlerConfig, use_logger, use_logger_handler, use_verbose


PRICING = define_module("pricing", {
    "total": "amount / divisor",
    "RESULT": "total + 1",
})


async def calculate(logger: Logger, *, verbose: bool) -> object:
    with use_verbose(logger, enabled=verbose):
        async with define_frame(PRICING, preset={"amount": 39, "divisor": 0}) as frame:
            return await frame.get("RESULT")


async def main() -> None:
    with TemporaryDirectory() as directory:
        config = LoggerHandlerConfig(
            level="DEBUG",
            format="%(levelname)s | %(message)s",
            traceback_style="compact",
            console={"enabled": False},
            file={"diagnostic": {"directory": directory, "level": "DEBUG"}},
        )
        async with use_logger_handler(config) as runtime:
            logger = await use_logger("example.billing")
            try:
                await calculate(logger, verbose=True)
            except LclError as error:
                identity = error.verbose_failure_id
                assert identity is not None
                logger.exception("Order calculation failed")
            else:
                raise AssertionError("The zero divisor must fail")
        path = runtime.metrics.sinks["file.diagnostic"].path
        assert path is not None
        text = path.read_text(encoding="utf-8")
        assert "TRACE | Failed while evaluating total" in text
        assert "Cached failures: total, RESULT" in text
        assert f"(failure {identity})" in text
        assert text.count("Error in evaluating RESULT") == 1
        assert "[LCL131421: division by zero]" in text
        assert "Python traceback:" in text
        print("\n".join(text.splitlines()[1:]))


asyncio.run(main())

The program prints the diagnostic file's event records, including one complete exception report:

TRACE | Read amount from external input in Frame "LCL_IMPORTS": (int) 39 (evaluation E2)
TRACE | Read divisor from external input in Frame "LCL_IMPORTS": (int) 0 (evaluation E2)
TRACE | Failed while evaluating total [LCL131421: division by zero] (failure F3)
  Type: LclEvaluationError
  Source: "<string>":1:1
  Evaluation: E2
  Request: RESULT -> total
  Frames: requested from "pricing"; defined in "pricing"
  Lookup path: pricing
  Execution: new evaluation
  Task: Task-3
  Phase: arithmetic operation
TRACE | Could not evaluate RESULT (failure F3)
  Cached failures: total, RESULT
ERROR | Order calculation failed
Error in evaluating RESULT [LCL131421: division by zero] (failure F3)
├─ Cause
│  └─ ZeroDivisionError: division by zero
└─ RESULT: total + 1 ("<string>":1:1)
   └─ total: amount / divisor ("<string>":1:1)
      ├─ amount: (int) 39 (external)
      ├─ divisor: (int) 0 (external)
      └─ Source: "<string>":1:1
         amount / divisor
         ^^^^^^^^^^^^^^^^
Python traceback:
LclEvaluationError [LCL131421: division by zero] (failure F3)
├─ Direct cause
│  └─ ZeroDivisionError: division by zero
└─ Traceback (most recent call last)
   ├─ File "<string>", line 33, in main
   │      await calculate(logger, verbose=True)
   └─ File "<string>", line 18, in calculate
          return await frame.get("RESULT")
                 ^^^^^^^^^^^^^^^^^^^^^^^^^

calculate closes its Frame before main records the exception. The first verbose failure explains the arithmetic stage and request context; propagation and committed caches form a short root summary. logger.exception adds the complete dependency tree, retained values, cause and Python stack. Their shared failure identifier survives exit from use_verbose. Treat it as a process-local reading aid; include your own request identifier for correlation across workers or restarts.

Use logger.exception inside an active except block. For a retained exception reported later, pass exc_info=(type(error), error, error.__traceback__) explicitly. Formatting str(error) into the message and also supplying exc_info would repeat the full diagnostic. A layer that logs and rethrows needs a clear agreement with its caller about which boundary owns the final report.

When using CLI or workflow, let their existing failure boundaries report the final error. Add application facts through ordinary events; logging the same exception inside every command, action and wrapper produces repeated reports. Preserve native cancellation, exit and interruption. Catch LclError or the specific recoverable type, and retain unhandled members when processing groups; see exceptions and grouped failures.

use_verbose is a synchronous context manager that spans await. Nested enabled=False or use_verbose(None) suppresses an outer verbose scope; exit restores it. Child tasks inherit the context at creation, so await them before their borrowed logger's handler scope closes. Disable the scope or reject DEBUG at the producer when tracing is unnecessary: both skip verbose-only rendering. Handler filters act on records after their messages have been constructed.

Keep an existing application logging system

An application can use LCL tracing and exception rendering without a lclang handler scope. It continues to own its stdlib logger, handlers, levels and filters. LclExceptionFormatter supplies the shared diagnostic tree and compact/full Python stack policy on a handler the application selects.

This example filters verbose records at the handler while keeping an application event. Removing that filter admits execution diagnostics. Disabling use_verbose also keeps the business event, and avoids constructing trace-only messages.

import asyncio
import io
import logging

from lclang.lang import define_frame, define_module
from lclang.logger import LclExceptionFormatter, use_verbose


class BusinessOnly(logging.Filter):
    def filter(self, record: logging.LogRecord) -> bool:
        return not bool(getattr(record, "lclang_verbose", False))


async def calculate(logger: logging.Logger, *, verbose: bool) -> None:
    module = define_module("pricing", {"RESULT": "amount * 2"})
    with use_verbose(logger, enabled=verbose):
        async with define_frame(module, preset={"amount": 3}) as frame:
            result = await frame.get("RESULT")
    assert result == 6
    logger.info("Calculated total=%d", result)


async def main() -> None:
    output = io.StringIO()
    logger = logging.Logger("example.existing", logging.DEBUG)
    handler = logging.StreamHandler(output)
    handler.setFormatter(LclExceptionFormatter(
        "%(levelname)s | %(message)s", traceback_style="compact"
    ))
    selected = BusinessOnly()
    handler.addFilter(selected)
    logger.addHandler(handler)
    try:
        await calculate(logger, verbose=True)
        assert output.getvalue() == "INFO | Calculated total=6\n"
        print("With the handler filter:")
        print(output.getvalue(), end="")
        handler.removeFilter(selected)
        output.seek(0)
        output.truncate(0)
        await calculate(logger, verbose=True)
        assert "TRACE | Evaluated RESULT" in output.getvalue()
        assert "RESULT: amount * 2 (" in output.getvalue()
        print("With verbose enabled:")
        print(output.getvalue(), end="")
        output.seek(0)
        output.truncate(0)
        await calculate(logger, verbose=False)
        assert output.getvalue() == "INFO | Calculated total=6\n"
        print("With verbose disabled:")
        print(output.getvalue(), end="")
    finally:
        logger.removeHandler(handler)
        handler.close()


asyncio.run(main())

The captured output shows that filtering and disabling verbose both retain the business message:

With the handler filter:
INFO | Calculated total=6
With verbose enabled:
TRACE | Read amount from external input in Frame "LCL_IMPORTS": (int) 3 (evaluation E2)
TRACE | Evaluated RESULT: (int) 6 (evaluation E2)
TRACE | Evaluation tree for RESULT (evaluation E2)
  Result: (int) 6
RESULT: amount * 2 ("<string>":1:1)
└─ amount: (int) 3 (external)
INFO | Calculated total=6
With verbose disabled:
INFO | Calculated total=6

use_verbose borrows the logger and restores only its tracing context. The application installs and removes this example's handler. In a real application, keep an existing handler for its normal lifetime. Preserve one delivery route per intended sink: a logger with its own handler and propagation to a parent handler can deliver the same record twice.

Filters can select lclang_verbose, lclang_stage, lclang_evaluation_id, lclang_failure_id, lclang_context and lclang_tree. Evaluation/failure IDs can be None. These fields contain detached diagnostics; external formatters should not request more Frame values or reload configuration while formatting. Changing traceback_style to "full" restores all Python frames. Compact keeps application and external-library frames, including a Python callback that calls LCL again. Both styles retain exception chains and groups and leave the original exception untouched. See text diagnostics.

Choose a policy for uncaught failures

Applications that let LCL failures escape to Python can explicitly call install_exception_hooks at startup. It reports the LCL tree to stderr, followed by the Python exception tree. It does not create a file logger. By default it also handles worker-thread failures; include_threads=False leaves worker-thread hooks unchanged. Pass loop=your_loop to cover that particular asyncio loop's unhandled task failures, installing and restoring on its owning thread.

Save the following program as application.py. Run python application.py for a normal result or python application.py 0 for an uncaught division failure. The program restores the previous hook after a normal return. If the calculation raises, Python reaches the installed hook before restoration.

import asyncio
import sys

from lclang.lang import define_frame, define_module
from lclang.logger import install_exception_hooks


async def business(divisor: int) -> object:
    async def callback(amount: int) -> object:
        module = define_module("inner", {"value": "amount / divisor"})
        async with define_frame(module, preset={"amount": amount, "divisor": divisor}) as inner:
            return await inner.get("value")

    module = define_module("outer", {"RESULT": "callback(39)"})
    async with define_frame(module, preset={"callback": callback}) as outer:
        return await outer.get("RESULT")


async def main() -> None:
    divisor = int(sys.argv[1]) if len(sys.argv) > 1 else 3
    result = await business(divisor)
    assert result == 13
    print(f"Result: {result}")


previous = sys.excepthook
installation = install_exception_hooks(traceback_style="compact", include_threads=False)
asyncio.run(main())
installation.restore()
assert sys.excepthook is previous

This launcher runs both commands and prints their captured output. For the failed command it prints the LCL diagnostic at the beginning of stderr; the complete stderr also contains the Python traceback. Its assertions check the application frames and compact filtering without publishing installation paths.

import os
import subprocess
import sys


environment = {**os.environ, "PYTHONIOENCODING": "utf-8"}
success = subprocess.run(
    [sys.executable, "application.py"], env=environment,
    capture_output=True, encoding="utf-8", check=True,
)
assert success.stdout == "Result: 13.0\n" and success.stderr == ""
print(success.stdout, end="")
failure = subprocess.run(
    [sys.executable, "application.py", "0"], env=environment,
    capture_output=True, encoding="utf-8", check=False,
)
assert failure.returncode == 1 and failure.stdout == ""
assert "in business" in failure.stderr and "in callback" in failure.stderr
frames = [line for line in failure.stderr.splitlines() if 'File "' in line]
assert not any("/src/lclang/" in line.replace("\\", "/") for line in frames)
diagnostic, separator, traceback = failure.stderr.partition("\nPython traceback:\n")
assert separator and traceback
print("Uncaught stderr diagnostic:")
print(diagnostic)

The normal result and the failed run's stderr diagnostic are:

Result: 13.0
Uncaught stderr diagnostic:
Error in evaluating RESULT [LCL131421: division by zero]
├─ Cause
│  └─ ZeroDivisionError: division by zero
└─ RESULT: callback(39) ("<string>":1:1)
   └─ callback: Python function business.<locals>.callback ("application.py":9)
      ├─ arg[0]: (int) 39 (argument)
      └─ value: amount / divisor ("<string>":1:1)
         ├─ amount: (int) 39 (external)
         ├─ divisor: (int) 0 (external)
         └─ Source: "<string>":1:1
            amount / divisor
            ^^^^^^^^^^^^^^^^

The failed run includes the Python callback's definition location and the LCL requests nested inside it. Compact mode retains application frames around both LCL calls; full mode restores the intervening lclang Python frames. No failure ID appears here because verbose is disabled. Hooks do not manufacture a verbose execution history.

Keep hooks active until the actual uncaught-report boundary. Restoring them in a finally around asyncio.run disables their report before Python invokes sys.excepthook. restore() is idempotent and preserves later third-party hook replacements. Pure native exceptions and control signals delegate to the prior handlers. A host with its own exception reporting can keep that policy and use the formatter on its logging boundary instead.

Choose one stderr failure owner. If you log a failure to stderr and then leave it uncaught with hooks installed, those two boundaries each produce a report. A program that needs a file record plus the uncaught stderr report can use an application-owned file handler for the caught record, then rethrow to the hook. CLI already reports failures after cleanup and does not install exception hooks.

Protect values and finish shutdown

LCL diagnostics retain existing binding masks and use bounded, protected value summaries. Application messages have their own disclosure policy: passing a raw token to logger.info("token=%s", token) exposes it. Log selected business fields, omit secrets, or use the masking-aware business event helpers covered in Python utilities. Avoid copying an entire configuration or request object into a log record.

Await producers and close business resources before leaving the handler scope. Normal, failed and cancelled exits drain, flush and join the writer before restoring logging state. Do not use a borrowed Logger afterwards. Startup can fail when an enabled sink cannot initialize; handle that at the application's startup boundary, where the logging runtime is not yet usable.

After shutdown, inspect runtime.metrics.writer_errors and per-sink metrics when delivery matters. records_written counts a record once if any sink wrote it; it is not a guarantee that every configured sink succeeded or that bytes reached durable storage. Runtime sink failures are isolated and reported to sys.__stderr__. Forced termination or permanently blocked I/O cannot guarantee a completed drain. The logger reference defines the remaining rotation, recovery and shutdown contracts.

Previous: Configuration composition | Return to the series introduction