Skip to content

Logging

A11 emits native C++ logs as ordinary Python logging.LogRecord values. The native module forwards Abseil entries to the a11.native logger, where levels, dictConfig, formatters, handlers, and pytest's caplog apply.

Importing A11 uses the process configuration

import a11 selects a level from the surrounding process in this order:

  1. absl-py, if the process configured itset_verbosity() was called, --verbosity was passed, or absl.logging's handler is on the root logger.
  2. The standard library — the effective level of the a11 logger, which inherits logging.basicConfig and logging.config.dictConfig.
  3. A11_LOG_LEVEL — a name (debug, info, ...) or an integer, read only when neither of the above says anything.

Without a configured level, A11 uses WARNING. Importing A11 prints nothing and installs no handler.

import logging

logging.basicConfig(level=logging.INFO)

import a11  # follows the line above

Enabling logging explicitly

import a11

a11.enable_logging("debug")
a11.get_logger(__name__).info("ready")

enable installs a handler only when the root logger has none, and the one it installs is absl.logging's, so output keeps Abseil's shape:

I0810 11:05:21.115550 8533073600 shell.py:64] opened shell 1
I0810 11:05:21.115635 8533073600 http2.cc:1691] HTTP/2 listener error

Under an application that has already configured logging, A11 installs nothing and its records — native ones included — flow into the handlers you set up.

set_level moves the a11 logger, absl.logging's verbosity, and the native VLOG threshold together; disable silences both sides.

Filtering and reconfiguration

Levels are resolved per record, so a setLevel or dictConfig that lands after import takes effect with nothing to re-synchronise:

logging.getLogger("a11").setLevel(logging.DEBUG)     # native logs included
logging.getLogger("a11.native").setLevel(logging.ERROR)  # native logs only

A logging.Filter reaches native records the same way it reaches any other: on a handler, or on the a11.native logger itself. Python does not consult an ancestor logger's filters for a record that merely propagates through it, so one attached to a11 will not see them.

Only VLOG is gated natively, because it is the one genuinely costly tier. Call sync after external logging reconfiguration to pass a sub-DEBUG level to the native runtime:

logging.config.dictConfig(my_config)
a11.logging.sync()

Levels below DEBUG

A11 follows absl-py's convention: a standard level under DEBUG selects an Abseil VLOG tier, so logging.DEBUG - 1 enables VLOG(2).

What actions log

Action.log and Action.logf report action progress, while the logging bridge reports runtime events. Action logs travel as chunks on a reserved port, so a remote caller receives structured data instead of process-local stderr text.

await action.log("searching", channel="fetch")
await action.logf("read %d of %d pages", done, total)
await action.log({"hits": 12}, level="debug", internal=True)

The reserved port is created only when used and requires no schema declaration. Only a running action may log; logging before run or from the calling side of call has no active writer or reader.

What is consumed in this process becomes a record on the a11.action logger, so setLevel, dictConfig and your existing handlers apply. The chunk's whole description travels with it as record attributes -- a11_action, a11_channel, a11_internal, a11_mimetype, a11_data -- so a handler can filter on the channel or drop A11's internal lines without parsing the message back apart.

A11_ACTION_LOG=0 leaves them on the native log instead. set_action_log_sink takes them somewhere else entirely. A single sink prevents duplicate delivery. Calling action.get_log_node() returns the chunks directly and suppresses the sink for that action.

In Flow the same log is two statements and two pipeline stages:

log warning "no readable content"
logf "read %s pages" read after search
pages | log debug it -> kept

Escape hatch

A11_LOG_BRIDGE=0, or enable(bridge=False), leaves native entries on Abseil's own stderr path instead of routing them through logging. Use it when debugging the bridge, or when native threads must not touch the GIL.

LOG(FATAL) always goes straight to stderr with a backtrace, bridged or not: it precedes process death, and there is nowhere else for it to go.

a11.logging

Logging for A11, wired into the standard library's.

A11's runtime is C++, but its logs are ordinary logging.LogRecord values. A sink inside the native module hands each Abseil entry to Python, which emits it on the a11.native logger, so the whole of logging applies to native output: levels, logging.config.dictConfig, pytest's caplog, a JSON formatter, a file handler. There is no second logging system to configure.

Importing a11 reads the level from the surrounding process rather than choosing one, in this order:

  1. absl-py, if the process configured itset_verbosity() was called, --verbosity was passed, or absl.logging's handler is on the root logger.
  2. The standard library — the effective level of the a11 logger, which inherits logging.basicConfig and dictConfig.
  3. A11_LOG_LEVEL — a name (debug, info, ...) or an integer, read only when neither of the above says anything.

With none of those, the level is logging.WARNING, the same default a bare interpreter gives you, and import a11 prints nothing.

To take control:

import a11

a11.enable_logging("debug")             # or a11.logging.enable(logging.DEBUG)
a11.logging.get_logger(__name__).info("ready")

enable installs a handler only when the root logger has none, and the one it installs is absl.logging's, so the text keeps Abseil's shape. Under an application that has already configured logging, A11's records simply flow into it.

A11_LOG_BRIDGE=0 (or enable(bridge=False)) leaves native entries on Abseil's own stderr path instead of routing them through Python.

get_logger

get_logger(name: str | None = None) -> Logger

The a11 logger, or a child of it.

A dotted module name is placed under the A11 namespace, so get_logger(__name__) inside a11.gateway.app yields a11.gateway.app and inherits whatever is configured for a11.

parse_level

parse_level(level: int | str) -> int

Coerce level to a standard logging level.

Accepts an integer, a standard name ("DEBUG", "warning"), or a decimal string. Values below logging.DEBUG select Abseil's VLOG tiers, matching absl.logging's own convention.

set_action_log_sink

set_action_log_sink(callback: Any | None = None) -> None

Route what actions log somewhere other than A11's own logger.

callback(action_name, action_id, level, channel, file, lineno, internal, mimetype, data, unix_seconds) is called for each log an action writes and nothing else consumes -- an action whose log port a consumer claimed does not reach a sink at all. None restores the default, which is a record on the ACTION_LOGGER_NAME logger (or, with A11_ACTION_LOG=0, the native log).

There is one slot rather than one sink per interested party, so a caller that takes it takes it from whoever had it. Nothing is reported twice as a result, which is the point.

set_level

set_level(level: int | str) -> int

Set the level for A11's Python and native logging alike.

Applies to the a11 logger, to absl.logging's verbosity (which A11's own modules log through), and to the native VLOG threshold. Returns the standard level applied.

get_level

get_level() -> int

The effective standard level of the a11 logger.

sync

sync() -> None

Re-read the effective Python level and push it to the runtime.

Records are filtered in Python, so this is only needed to pick up a VLOG tier after reconfiguring logging behind A11's back — a logging.config.dictConfig that puts the a11 logger below logging.DEBUG.

enable

enable(level: int | str = INFO, *, bridge: bool = True) -> int

Turn A11's logging on at level and return the level applied.

A handler is installed only when the root logger has none, and it is absl.logging's, so output keeps Abseil's shape. Under an application that already configured logging, this only sets levels.

Pass bridge=False to leave native entries on Abseil's own stderr path rather than routing them through logging.

disable

disable() -> None

Silence A11's logging, native side included.