Controller and request logging

For executable examples and captured logs from the main process, API handler, Worker, hot reload and exception boundaries, see logging practices.

Native verbose control

Start with lcl-fastapi serve -c service.lclcfg --verbose to enable native LCL diagnostics. The same --verbose flag controls the short-lived lclang CLI scope and the deferred service's downstream scopes. The Boolean __verbose__ binding is available to .lclcfg expressions and get_config("__verbose__"); it is framework-owned and cannot be redefined. Omitting the flag selects False.

Enabled service sinks admit DEBUG records while preserving source filters, timezone, exception style, and disabled sinks. Business lifespan, API requests and background Worker evaluation use public use_verbose(logger, enabled=...). Requests retain their IDs on diagnostic records; Workers use their own file routing. Spawned and replacement workers receive the original flag. The playground passes it to its bounded trace capture.

A local with use_verbose(logger, enabled=False): silences tracing until scope exit, including child tasks created inside that scope. In a synchronous Worker, put that scope inside the coroutine passed to context.run() so it runs in the Worker Runner's context. The caller owns child tasks and must finish them before the logger closes. Turning off tracing preserves ordinary records and exception diagnostics. logger.traceback_style remains an independent compact/full policy.

Sinks and timestamps

Controller-owned lifecycle events use timestamp | level | pid | message. They omit thread, logger name, source filename and helper function fields. Other records retain the configured native format and timezone.

Every HTTP invocation produces one multiline REST API started record before business dispatch and one REST API completed record after response/error handling. Each contains a native text tree with the API method/path, generated request ID, worker PID and connection metadata. Completion adds the observed status and elapsed milliseconds. Paths exclude queries; bodies and headers are not logged. Allocation failures retain request_id=unavailable and a 503, with native error type/code in the single completion record. No ID is invented.

Service exception reports hide Python frames inside the installed FastAPI and Starlette packages in both compact and full modes. Business frames, causes, notes, groups and LCL diagnostic trees remain. Full mode additionally retains lclang Python frames. Filtering changes the rendered tree and never rewrites the original exception or traceback.

Configure one shared directory and two filename expressions using the native lclang.logger schema:

# Logging and file destinations
logger.file.default.directory: "./logs"
logger.file.controller.filename: f"{app.name}.controller.log"
logger.file.service.filename: f"{app.name}.{worker_pid}.log"
logger.level: "INFO"
logger.timezone: "local"
logger.file.service.rotation.mode: "size"
logger.file.service.rotation.max_bytes: 10485760

Current source requires lclang>=1.1.0. Log timestamps default to the server's local time with microseconds and a numeric offset, such as 2026-10-08T15:30:00.123456+08:00. Set logger.timezone: "utc" or pass -o logger.timezone utc to use timestamps ending in Z. These settings apply to controller, API and background logs. Verbose mode preserves the timezone. Only "local" and "utc" are accepted; non-text values and named timezones are rejected before service sinks open, with source-aware diagnostics. Segment filenames and aligned rotation continue to use UTC. The separate short-lived CLI stderr logger retains its own local-time default.

logger.file.controller belongs exclusively to the service controller. logger.file.service retains its existing name and belongs exclusively to each worker. The default directory is inherited by both; native per-sink level, rotation, and directory settings remain available. Additional named sinks belong to workers. Controller resolution never evaluates worker filenames. Workers do not open the controller sink. Native masked bindings retain their masking in the selected role.

When background callables are registered, their matching named sinks are reserved for those background threads; other additional sinks remain API sinks. See background file routing for shared-writer ownership, source namespacing and controller lifecycle events.

Controller files record complete-service start/stop/failure, heartbeat, observed worker up/down transitions, hot-reload retirement, and observed worker log segment changes. Observations follow health.sample_interval_seconds and the native manager loop, so they are eventually consistent and can miss workers that start and exit between samples. Controller messages are file-only. The controller keeps an upstream handler scope open between events. Before Gunicorn forks a worker, it drains the writer and closes its event loop and executor; only the parent reopens the scope after the fork. This starts a new upstream segment per worker creation, rather than per heartbeat. No logger thread or file handler survives into the fork. Windows spawns workers and retains the controller scope until service exit. Worker request and business logs retain their worker-long handler scope.

worker_pid is supplied inside the real worker. Defining it in the application file is rejected. LCL computes the filename, while lclang owns permanent segments, rotation suffixes, writes, and flush. There is no logger.log_dir or logger.filename compatibility alias.

Use await get_logger(prefix="", emit_level=0) inside an active lifespan or request. The prefix names the underlying logger and prefixes its messages. The returned wrapper reads request identity each time a message is emitted, before the record reaches lclang's writer thread. A logger reused between requests does not retain the ID of the request that created it.

from lcl_fastapi import LclFastAPI, get_logger, get_request_context

service = LclFastAPI()


@service.get("/work")
async def work() -> dict[str, str]:
    context = get_request_context()
    assert context is not None
    logger = await get_logger("example.work")
    logger.info("work completed")
    return {"request_id": context.request_id}

RequestContext contains request_id, method, path, and monotonic started_ns. Outside a request, get_request_context() returns None. During requests, the ID is attached both to the log record's request_id extra and to the message so the default lclang formatter displays it. emit_level skips that many additional business wrapper frames; the normal file/function/line point to the business call site rather than the framework wrapper.

The framework records paired multiline invocation trees for each HTTP request, including failures, with method, path, status code, duration in milliseconds, worker PID, and Request ID. Uvicorn and Gunicorn access loggers are disabled inside the worker scope after logger takeover to prevent duplicate access records, and their prior flags are restored on exit. Query strings, credentials, cookies, and request bodies are not automatically logged.

If the upstream Snowflake generator reports an invalid epoch (LclValidationError), clock rollback (LclStateError) or timestamp/sequence exhaustion (LclUtilityError), the framework returns HTTP 503 with {"detail": "Request ID generation unavailable"}. Business routing is not entered. This response has no generated request-ID header or request-state ID. The completion record explicitly contains request_id=unavailable and id_generation_error naming the native exception type and id_generation_code containing its LCL error code; it never borrows a prior request's ID. Configuration and request-context bindings are restored even if sending the failure response is cancelled or fails. The framework does not retry, synthesize a fallback ID, or change the upstream clock/sequence algorithm. A later request can succeed once the upstream generator can issue an ID again.

Uncaught exception diagnostics

The default HTTP error callback submits the original exception and its traceback to the native logger. Reports retain causes, contexts and ordinary exception-group members. LCL failures include their error code, failed dependency path, source excerpt and correlation identifiers. Native diagnostics preserve declared masks. Masks protect named LCL diagnostic values; exception messages and application text follow their producers' policies.

logger.traceback_style accepts "compact" (default) or "full". Both hide installed FastAPI/Starlette frames. Compact reports retain business and other external-library frames; full reports also include lclang frames. Select the same policy with -o logger.traceback_style full. Controller, API and background logs share the selected policy. Invalid values fail before service sinks open.

The callback does not inspect function arguments or read the HTTP body. The native writer formats records away from the API event loop. Custom callback failures are logged before the original exception's default diagnostic. Producer logging failures receive a short best-effort stderr summary without recursive callbacks; native writer failures follow the upstream sink-isolation and cleanup policy.

Rotation and permanent segments

The upstream lclang writer owns rotation. Without an explicit policy, rotation mode is none: opening a logger scope still creates a fresh segment, but size and time do not switch it. Configure a shared policy, then specialize either sink:

# Logging and file destinations
logger.file.default.rotation.mode: "size_or_time"
logger.file.default.rotation.max_bytes: 10485760
logger.file.default.rotation.interval: "1d"
logger.file.default.rotation.align: True
logger.file.controller.rotation.max_bytes: 1048576

Both sinks rotate at midnight UTC or their size threshold, whichever happens first. The controller uses 1 MiB and each worker uses 10 MiB. These are per-file thresholds, not a directory quota. Explicit sink fields override shared fields.

Mode Required fields Trigger
none None No automatic rollover.
size Positive integer max_bytes Before the next record would exceed the encoded-byte threshold in a segment that already has a record.
time interval Writer timer, including while idle.
size_or_time Both Either trigger; size rollover does not reset the time schedule.

Intervals are positive integers followed by s, m, h, or d, such as 30s or 6h. align: False (default) measures elapsed intervals from scope startup; align: True accepts only 1h or 1d and uses UTC boundaries. Records are never split, so a large first record and metadata can exceed max_bytes.

A configured catalog-api.28146.log becomes, for example, catalog-api.28146.20260912T100000.123456Z.000001.log: the writer inserts a UTC timestamp and a process-wide sequence before the suffix. Sequence numbers can have gaps because other sinks share the counter. Old segments are neither renamed, overwritten, compressed, nor automatically deleted. Arrange retention for closed files through your operator's archival process; rotation alone does not bound total disk usage. Restarting a worker creates a new segment too. On Linux, controller scope reopening around worker forks also creates segments; this does not imply that a size/time threshold was reached.

Each file begins with log file: "<absolute path>". On rollover the old file ends with continued in: "<successor absolute path>". Paths are JSON-escaped, including backslashes on Windows. An ordinary scope close has no continuation footer. Follow those links to read a rotated stream; use logs JSON to discover the currently observed worker segments.

This isolated example exercises the same native writer without starting a service. A deliberately tiny threshold forces intact records into new files; exiting the scope flushes them before inspection:

import asyncio
from pathlib import Path
from tempfile import TemporaryDirectory

from lclang.logger import LoggerHandlerConfig, use_logger, use_logger_handler


async def demonstrate_rotation() -> None:
    with TemporaryDirectory() as directory:
        config = LoggerHandlerConfig(
            console={"enabled": False},
            file={"service": {
                "directory": directory,
                "filename": "demo.log",
                "rotation": {"mode": "size", "max_bytes": 1},
            }},
        )
        async with use_logger_handler(config):
            logger = await use_logger(name="rotation-demo")
            logger.info("first intact record")
            logger.info("second intact record")
        segments = sorted(Path(directory).glob("demo.*.log"))
        assert len(segments) == 2
        first, second = [path.read_text(encoding="utf-8") for path in segments]
        assert first.startswith("log file: ") and "continued in: " in first
        assert "first intact record" in first
        assert "second intact record" in second and "continued in: " not in second


asyncio.run(demonstrate_rotation())

Log file samples

These excerpts show message payloads with the default formatter's timestamp, level, PID/thread, logger, and source-location columns omitted for readability. PIDs, timestamps, paths, and request IDs are illustrative. Controller files contain messages such as:

log file: "/opt/catalog-api/logs/catalog-api.controller.20260912T100000.123456Z.000001.log"
service started service_pid=28140 workers=1
heartbeat service_pid=28140 workers=1
worker up worker_pid=28146
worker log rotate worker_pid=28146 paths=['/opt/catalog-api/logs/catalog-api.28146.20260912T100010.123456Z.000002.log']
hot-reload retiring worker_pid=28146
worker down worker_pid=28146
service stopped

Transitions appear when observed, so their order relative to heartbeats varies. Failure exits can include service failed. Controller records describe service operations and have no HTTP request ID. A worker's catalog log contains:

log file: "/opt/catalog-api/logs/catalog-api.28146.20260912T100000.123456Z.000001.log"
catalog catalog ready
catalog request_id=123456789 list products
lcl_fastapi request_id=123456789 REST API started
├─ method=GET path=/api/v1/products
│  └─ request_id=123456789
└─ context
   ├─ worker_pid=28146
   ├─ client=('127.0.0.1', 50000)
   └─ scheme=http
lcl_fastapi request_id=123456789 REST API completed
├─ method=GET path=/api/v1/products status_code=200 duration_ms=0.420
│  └─ request_id=123456789
└─ context
   ├─ worker_pid=28146
   ├─ client=('127.0.0.1', 50000)
   └─ scheme=http
continued in: "/opt/catalog-api/logs/catalog-api.28146.20260912T100010.123456Z.000002.log"

The business prefix is catalog; the matching response header is X-Request-ID: 123456789. Search that ID to correlate the business and access records. Startup catalog ready and teardown catalog closed run outside a request and therefore have no request ID. Teardown may be in a later segment. The default formatter includes the business call site for business messages.

Request IDs

Each actual worker owns one lclang.utils.SnowflakeGenerator for its lifespan. The pure ASGI middleware calls next_id() once per HTTP request and uses its decimal string as the server ID. On success it binds that ID in the task-local RequestContext, request.state.request_id, and the configured response header (default X-Request-ID). A client-supplied ID never replaces this value. The framework leases concurrent worker IDs but does not implement Snowflake's timestamp, sequence, bit composition, clock handling, or locks.

Observing active files

lcl-fastapi logs -o config service.lclcfg

The result contains paths, observed_at, and stale. Paths come from lclang's actual file-sink metrics published by live workers. They are never guessed from directory modification times. observed_at is the oldest contributing Unix timestamp, or null without an observation. stale identifies a live worker's overdue view; exited workers' paths are excluded.

Rollover is eventually consistent: a normal change becomes visible by the next health.sample_interval_seconds refresh plus metric sampling and the state write. Refresh continues when the health endpoint is disabled. Inspecting paths does not stream or read log content. Business teardown logs are flushed before worker shutdown finishes.