Scoped structured context

Updated Oct 15, 2026

Use log_context(...) to add structured key-values to every record emitted in the current Python task while the context is active:

import femtologging

logger = femtologging.get_logger("service")
with femtologging.log_context(request_id=42, user="alice"):
    logger.info("request accepted")

Behavioural guarantees:

  • Python context uses contextvars.ContextVar semantics. It is safe to hold log_context(...) across an await: another task on the same event-loop thread cannot observe its fields. A child task inherits the snapshot that was active when it was created, and later changes stay task-local.
  • Python context values are captured when a Python logging API creates its record and are merged before queueing.
  • Inline structured fields emitted by Rust macros override outer context keys.
  • Context values must be str, int, float, bool, or None.
  • Callback-filter enrichment uses the same scalar contract: keys must be non-empty strings, and values must be str, int, float, bool, or None.
  • Keys must not collide with stdlib logging.LogRecord attributes or femtologging-reserved metadata names.
  • Enrichment is bounded to 64 keys per record, 64 UTF-8 bytes per key, 1,024 UTF-8 bytes per value, and 16 kibibytes (KiB) total serialized enrichment per record.

Rust context and bridge boundaries

Rust push_log_context remains OS-thread-local. Its LogContextGuard is neither Send nor Sync, records the thread that created it, and removes only its own frame when dropped. Do not hold a Rust guard across an .await in a future that may migrate between executor threads; use a task-local API supplied by that runtime instead.

Python task-local fields and Rust scoped fields do not transfer implicitly. Python FemtoLogger methods and module-level functions capture only the active Python fields. Rust macros and records received through the log and tracing bridges merge Rust scoped fields active on their emitting OS thread. Explicit key-values supplied by Rust macros or tracing events override same-named scoped fields. Establish Rust context at the Rust emission boundary when bridged events need shared request metadata.

Callback enrichment validation

Python callback filters may mutate the temporary logging.LogRecord object and return a truthy result to accept the record. femtologging copies only the validated scalar additions from that record into Rust-owned metadata before the record is queued.

Accepted enrichment must satisfy all of the following:

  • Keys are non-empty str instances.
  • Values are limited to str, int, float, bool, or None.
  • Keys do not overwrite built-in logging.LogRecord fields such as name, msg, levelname, created, or femtologging-reserved metadata fields.
  • The serialized payload stays within 64 keys, 64 UTF-8 bytes per key, 1,024 UTF-8 bytes per value, and 16 KiB total.

Example of accepted enrichment:

import logging

import femtologging


def request_filter(record: logging.LogRecord) -> bool:
    record.request_id = "req-123"
    record.user_id = 42
    record.cache_hit = False
    return True


logger = femtologging.get_logger(
    "service",
    filters=[femtologging.PythonCallbackFilterBuilder(request_filter)],
)

Collisions and invalid values are rejected before the record enters the worker thread. For example, this filter tries to overwrite a stdlib field and attach an unsupported value:

import logging


def invalid_filter(record: logging.LogRecord) -> bool:
    record.name = "rewritten"
    record.payload = {"not": "allowed"}
    return True

When validation fails, femtologging raises a Python exception inside the callback path, catches it at the logger boundary, emits a warning, and drops that record. The process keeps running, but the invalid record is not queued or delivered to handlers.