Skip to content

without_logging

A without pipeline for logs: stdlib log records parsed into immutable values, filtered and enriched as processors, drained to a sink.

without_logging

CaptureHandler

CaptureHandler(
    loop: AbstractEventLoop,
    queue: Queue[Record],
    parse: Callable[[LogRecord], Record],
)

Bases: Handler

A stdlib handler that parses each LogRecord and offers it to an asyncio queue.

This is the one-way bridge at the ingestion edge. Stdlib logging is the de-facto narrow waist every third-party library already writes to, so making it a source (rather than fighting it) is the whole trick: a pushed LogRecord is parsed to a Record here and dropped onto the queue that stream_from_queue turns into the pull-based stream the pipeline consumes. Control only ever flows outward from stdlib into without, never back.

A log call MAY happen off the event loop thread, so the parsed value is handed to the loop with call_soon_threadsafe. The queue is bounded, so a burst that outruns the sink is dropped rather than growing memory without limit; dropped counts how many so an operator can see it. The overflow policy is a boundary decision the app owns: raise capacity, or pass capacity=None for an unbounded queue that never drops (at the cost of the bound).

dropped instance-attribute

dropped = 0

emit

emit(log_record: LogRecord) -> None

Level

Bases: IntEnum

The five standard severities as an ordered value, for writing filters.

Members are the stdlib numeric levels, so a Record.level (a plain int) can be compared straight against them: record.level >= Level.WARNING. Keeping Record.level an int rather than this enum is deliberate: a third-party library that logs at a non-standard numeric level is carried through as that number, not rejected, so the enum is a vocabulary for predicates rather than a closed set the record must belong to.

DEBUG class-attribute instance-attribute

DEBUG = logging.DEBUG

INFO class-attribute instance-attribute

INFO = logging.INFO

WARNING class-attribute instance-attribute

WARNING = logging.WARNING

ERROR class-attribute instance-attribute

ERROR = logging.ERROR

CRITICAL class-attribute instance-attribute

CRITICAL = logging.CRITICAL

Record dataclass

Record(
    timestamp: datetime,
    level: int,
    logger: str,
    message: str,
    exception: TracebackException | None,
    fields: Mapping[str, object],
)

One log event as an immutable value, parsed from a stdlib LogRecord.

Where a LogRecord is a mutable bag that formatters and filters rewrite in place, a Record is a value: it always means the same thing, so it can be filtered, enriched, fanned out, and sunk without any stage disturbing another's copy. Enrichment returns a new record (with_fields) rather than mutating this one. fields is the record's structured data, exposed read-only: seeded from the log call's extra= at the parse edge, then grown by enrichment (add_fields, a bind merged by merge_context, any with_fields). It is named for what it holds, not for extra= alone, because those later sources write here too. exception is a TracebackException captured at the ingestion edge (None when the event carried none): a structured value, not a rendered string, so how the traceback is formatted stays a downstream boundary the app owns ("".join(exc.format()) for the stdlib text, or walk exc.stack for JSON frames), the same way rendering the record itself is. Extracting it drops all references to the live frames, so the record stays a self-contained value with no traceback pinned to it.

timestamp instance-attribute

timestamp: datetime

level instance-attribute

level: int

logger instance-attribute

logger: str

message instance-attribute

message: str

exception instance-attribute

exception: TracebackException | None

fields instance-attribute

fields: Mapping[str, object]

level_name property

level_name: str

with_fields

with_fields(**fields: object) -> Record

Return a copy of this record with fields merged onto its own.

bind

bind(**fields: object) -> Iterator[None]

Bind fields onto every record logged within this block, on this task.

The call-site half of structured context, the equivalent of structlog's bind_contextvars: with bind(request_id=...) stamps those fields onto every record logged inside the block. Scoping is explicit and lexical: the fields are in effect for the block and gone after it, leaving no shared place mutated. It writes a task-local ContextVar, so concurrent tasks each see only their own binds, and nested binds accumulate (an inner bind merges onto the outer and is undone on exit; a key set by both takes the inner value inside the inner block).

Binding alone does nothing until the capture side reads it: pair this with merge_context, which merges the bound fields onto each record at the parse edge, where they are still live (the handler's emit runs synchronously on the logging call's task). A downstream processor cannot read them, having left the caller's context for the sink task.

merge_context

merge_context(record: Record) -> Record

Merge the context bound by bind onto a record: the read half of call-site context.

A Record -> Record enrichment, composed on top of a parser rather than wrapping it: capture's default parser applies it after parse_record, and a custom parser composes it the same way, merge_context(parse_record(log_record)). A field the log call set explicitly via extra= wins over a bound field of the same name (the more specific value), so bind supplies defaults, never overrides a per-call value.

It reads the task-local ContextVar that bind writes, so it MUST run at the parse edge, in the handler's emit on the caller's task, not as a pipeline Processor: the pipeline runs in the sink task, having left that context, where the bind would read as empty.

add_fields

add_fields(**fields: object) -> Processor[Record, Record]

A processor that merges static fields onto every record: enrichment.

Enrichment is expressible as a from_map (one record in, one enriched record out), so it uses the core builder directly. Enriching from a shared behavior (a value the whole pipeline sees the latest of: the current config revision, a sampling rate) is the same shape, reading it via current() inside the step. Per-call-site context (a request or trace id, which is task-local) is not recoverable here: the pipeline runs in the sink task, having left the caller's context. Bind it at the edge instead with bind/merge_context, which read it while emit is still on the caller's task (see the guide).

at_least

at_least(level: int) -> Callable[[Record], Awaitable[bool]]

A predicate matching records at level or more severe: the level threshold.

Pair it with the core from_selector to keep only those records (or from_filter to drop them). It is async to match the one color of predicate from_selector/from_filter expect, though the decision itself just compares integers. Because a Record.level is a plain int, a Level member reads naturally as the argument (at_least(Level.WARNING)), but any numeric level works.

parse_record

parse_record(log_record: LogRecord) -> Record

Parse a mutable stdlib LogRecord into an immutable Record value.

The ingestion boundary: this is where a pushed LogRecord, from the app's own calls or any third-party library's, becomes the typed value the rest of the pipeline works with. It is a pure function (LogRecord -> Record), so the whole translation is testable without touching the logging machinery. The structured fields are every attribute on the record outside the standard envelope (RESERVED), which is exactly what logging's extra= merges in; the message is rendered and any exception is captured as a structured value here (see extract_exception) so nothing live is carried past this point.

exception_to_dict

exception_to_dict(
    exception: TracebackException,
) -> dict[str, object]

Convert a captured TracebackException into a JSON-serializable dict: structured traceback.

Each frame becomes {file, line, function, code} (from the extracted FrameSummary values, no live frames), and a chained exception is nested under cause, following Python's own rule: an explicit raise ... from (__cause__) takes precedence, else the implicit __context__ unless it was suppressed. This is the read half render_json uses, exposed so a custom JSON renderer can reuse it.

exception_to_text

exception_to_text(exception: TracebackException) -> str

Render a captured TracebackException to its full traceback text.

stdlib's own multi-line rendering (format), chained cause/context and all, as a single string. The flat-string alternative to the structured exception_to_dict when encoding a record's exception, and what render_console uses.

iso_timestamp

iso_timestamp(when: datetime) -> str

The default timestamp format for the renderers: ISO 8601 (datetime.isoformat).

render_console

render_console(
    *, timestamp: Callable[[datetime], str] = iso_timestamp
) -> Processor[Record, str]

A Record -> str renderer emitting one human-readable line per record.

TIMESTAMP LEVEL logger "message" {key=value, ...}: the message is quoted and the fields grouped in braces (each value repr'd), so the free-text message and the structured fields never blur into each other or the leading metadata. The braces are omitted when there are no fields. An exception is appended as its full traceback text (exception_to_text, cause chain and all), indented, on following lines. timestamp chooses the time format (default iso_timestamp). No coloring: wrap the line, or write your own from_map(Record -> str), if you want ANSI.

Optional and opt-in, like render_json: compose(render_console(), offload(to_stream(sys.stderr))).

render_json

render_json(
    *,
    timestamp: Callable[[datetime], object] = iso_timestamp,
    exception: Callable[
        [TracebackException], object
    ] = exception_to_dict,
) -> Processor[Record, str]

A Record -> str renderer emitting one JSON object per record: structured logging.

The envelope (timestamp ISO-8601, level, logger, message) and every field flat at the top level, so a log aggregator can index them directly; the envelope wins a name clash, so a field cannot shadow level or message.

timestamp chooses how the time is encoded (default iso_timestamp; pass, say, a lambda when: when.timestamp() for an epoch number). exception chooses how a traceback is encoded: exception_to_dict (default, structured frames, stays queryable) or exception_to_text (the flat traceback string), or any TracebackException -> <json value> of your own. A non-serializable field value is coerced with str (default=str): the log JSON is machine-consumed, so a clean indexable value beats a Python repr, and str still falls back to the repr form for an object without its own __str__ (render_console uses repr, matching its human-scanning medium). A stray value therefore never tears down the log pipeline.

Optional and opt-in: the core ships no mandatory formatter (encoding is the app's boundary), so compose this before a string sink yourself, e.g. compose(render_json(), offload(to_stream(sys.stdout))).

at_times

at_times(
    *times: time, tz: tzinfo = UTC
) -> Callable[[datetime], datetime]

A schedule for to_rotating_file: the next occurrence of any of these wall-clock times.

Given a moment, returns the soonest of times strictly after it, interpreted in timezone tz. So at_times(time(0, 0)) rotates daily at midnight tz, and at_times(time(0, 0), time(12, 0)) twice a day. The list of times is the recurring cycle of rotation boundaries, resolved to a concrete datetime against the actual current time each call, so nothing goes stale and a boundary missed while idle collapses to a single rotation. Strictly-after (not at-or-after) is what lets the writer advance past a boundary it just hit without looping on it.

offload async

offload(
    work: Callable[[Iterator[list[T]]], None],
) -> AsyncIterator[Sink[T]]

Run a blocking work on a dedicated thread, fed by the yielded async Sink.

The point is efficient blocking I/O under heavy logging. An async file library (aiofiles, anyio) hops to a worker thread per operation, so a busy log stream pays that round-trip on every write. Here a single long-lived thread owns the resource and does all the I/O: work is plain blocking Python that consumes the items, and the yielded Sink just drops each onto a thread-safe queue the worker drains. No per-item thread hop, and the "processor body" stays ordinary synchronous code.

Items arrive in bursts: each element of the iterator is everything available on the queue at that instant (at least one item, blocking for the first). A burst boundary is therefore exactly the moment the worker has caught up, which is where a writer flushes: under load bursts are large and flushes are few, and when idle each burst is a single item flushed at once. So durability needs no flush-frequency knob; it falls out of the queue's own backlog.

Lifecycle is bounded by the with block: the thread starts on entry, and on exit the queue is shut down so the worker drains what remains and then ends, and the thread is joined (so a file is closed before the block returns). If work raises, that surfaces when the block exits.

Nest this outside the consumer that drives the sink, so the worker outlives the draining. With capture, that means async with offload(...) as writer: around async with capture(..., writer):, not the other way round.

The queue is unbounded in this first cut: the async side never blocks or drops, at the cost of growing memory if the worker cannot keep up with a sustained burst (a stalled disk). A bounded, drop-counting variant is a deliberate follow-up. The bidirectional case (a full Processor bridged onto a thread, with an output queue as well) is intentionally out of scope; this covers the terminal-sink need (writing) without that extra state.

to_rotating_file

to_rotating_file(
    name: Callable[[int, datetime], Path],
    *,
    max_bytes: int | None = None,
    max_age: timedelta | None = None,
    schedule: Callable[[datetime], datetime] | None = None,
    now: Callable[[], datetime] = now_utc,
) -> Callable[[Iterator[list[str]]], None]

A blocking worker (for offload) that appends lines to a file, rotating by size and/or time.

Rotation lives in the worker deliberately: it is the one place with all the information to decide, timed with its own writes. Size is a function of the bytes written, so a size limit cannot be an independent, decoupled trigger; the worker keeps an exact byte count (writing UTF-8 bytes directly) rather than polling a lagging on-disk size, and reads the clock the same way. So max_bytes (size), max_age (elapsed since the file opened, a relative interval), schedule (the next absolute wall-clock boundary, e.g. midnight, via a next-boundary function such as at_times), and any combination (rotate on whichever trips first) all work here, which the stdlib's separate size and time handlers cannot do at once. At least one policy is required: with no limit the file would never rotate (an unbounded file), which is not the affordance the name suggests, so that case raises ValueError rather than being silently allowed.

schedule(t) returns the next rotation boundary strictly after t; the writer samples it at each open and rotates once now() reaches it (missed boundaries collapse to one rotation). It is the pure, thread-synchronous form of "a stream of rotation datetimes": the worker already owns the clock, so it generates the stream itself rather than sampling an async one.

name turns a rotation index and the rotation time into a path (lambda i, when: directory / f"app.{i}.log"); index 0 is the initial file, appended to if it already exists, and the time is that file's open time drawn from the same injected clock, so timestamped names stay consistent with the rotation decision. now is the clock, injected so time-based rotation is deterministic under test. This writes strings (render a Record to text in a from_map in front), owns the newline framing, and flushes at each burst boundary. The rotation decision is made before each write (so a size limit is not overshot, bar a single line larger than max_bytes), and it is inlined and short-circuiting: now() is read only when a size check has not already forced the rotation and an age limit is set.

to_stream

to_stream(
    stream: TextIO,
) -> Callable[[Iterator[list[str]]], None]

A blocking worker (for offload) that appends each string as a line to an open text stream.

The destination-shaped sibling of to_rotating_file, for a stream the caller already owns: sys.stderr, a socket's text wrapper, an in-memory buffer. It writes strings (render a Record to text with a from_map in front), owns the newline framing, and flushes at each burst boundary. Unlike to_rotating_file it does not open or close the stream: the caller's stream outlives this and may be shared, so closing it (sys.stderr, say) is not this worker's to do.