Logging¶
Cosalette produces structured JSON logs for production and human-readable text logs for development, with UTC timestamps, correlation metadata, and zero external dependencies.
Two Formats¶
The format is selected via logging.format in settings or --log-format
on the CLI:
NDJSON Format¶
JSON logs follow the NDJSON (Newline Delimited JSON) convention: one
complete JSON object per line, no embedded newlines. This is critical for
container log drivers (Docker, Kubernetes) that split on \n.
def format(self, record: logging.LogRecord) -> str:
entry = {
"timestamp": datetime.fromtimestamp(record.created, tz=UTC).isoformat(),
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"service": self._service,
}
if self._version:
entry["version"] = self._version
if record.exc_info and record.exc_info[0] is not None:
entry["exception"] = self.formatException(record.exc_info)
if record.stack_info:
entry["stack_info"] = self.formatStack(record.stack_info)
return json.dumps(entry, default=str) # (1)!
json.dumpswithdefault=strensures even unexpected types (e.g.pathlib.Path) serialise without raising. Tracebacks are escaped byjson.dumps, so multi-line exceptions become single-line JSON.
UTC Timestamps¶
All timestamps are UTC in RFC 3339 / ISO 8601 format:
JSON Fields¶
| Field | Type | Always present | Description |
|---|---|---|---|
timestamp |
string | Yes | ISO 8601 UTC timestamp |
level |
string | Yes | Python log level (INFO, WARNING, etc.) |
logger |
string | Yes | Dotted logger name (cosalette._app) |
message |
string | Yes | Formatted log message |
service |
string | Yes | Application name (for log correlation) |
version |
string | When non-empty | Application version |
exception |
string | When present | Formatted traceback |
stack_info |
string | When present | Stack trace (if stack_info=True) |
Correlation Metadata¶
Every log line includes service and version, enabling log aggregators to
filter and group entries without extra parser configuration:
# Loki query: all errors from velux2mqtt
{service="velux2mqtt"} |= "ERROR"
# Elasticsearch query: specific version
{"query": {"match": {"version": "0.3.0"}}}
Custom Formatter Over python-json-logger¶
Cosalette implements its own JsonFormatter (~70 lines) rather than depending
on python-json-logger:
| Consideration | Custom formatter | python-json-logger |
|---|---|---|
| Dependencies | Zero (stdlib only) | One additional package |
| Field control | Full — matches project schema | Library defaults, overridable |
| Container image | Smaller | Extra install step |
| Maintenance | Owned by project | Third-party release cycle |
configure_logging()¶
The configure_logging() function is called once during Phase 1 (Bootstrap):
It performs these steps:
- Clear existing handlers on the root logger (prevents duplicate output)
- Build formatter —
JsonFormatterfor"json",logging.Formatterfor"text" - StreamHandler →
stderr(always installed) - RotatingFileHandler → optional, when
settings.logging.fileis set - Redaction filter → on each of those handlers, when
redact=is set
Redaction¶
App(redact=...) is passed to configure_logging(..., redact=...), which
installs a filter on each handler it creates. The filter redacts a copy of
every record — the formatted message, the formatted traceback and the stack
info — so any other handler (a test's capture handler, say) still sees the
original. The filter sits on the handlers rather than on the cosalette
logger because a logger filter does not reach child loggers, while app and
library loggers all write through these handlers. JsonFormatter uses the
redacted traceback (record.exc_text) when one is present. The accepted
forms are described in
Error Handling; see
ADR-085.
Text Format String¶
Produces output like:
RotatingFileHandler¶
When logging.file is configured, logs are also written to a rotating file:
| Parameter | Value |
|---|---|
maxBytes |
settings.logging.max_file_size_mb (default: 10 MB) |
backupCount |
settings.logging.backup_count (default: 3) |
encoding |
UTF-8 |
The file handler uses the same formatter as the stream handler (JSON or text), so file logs and stderr logs are always in the same format.
Log Output Examples¶
{"timestamp": "2026-02-14T12:34:57.123456+00:00", "level": "CRITICAL", "logger": "cosalette._supervisor", "message": "Task 'device:blind' died (entities: blind): Connection refused", "service": "velux2mqtt", "version": "0.3.0", "exception": "Traceback (most recent call last):\n File \"_app.py\", line 268\n ...\nConnectionRefusedError: Connection refused"}
Configuration Reference¶
LOGGING__LEVEL=DEBUG # DEBUG, INFO, WARNING, ERROR, CRITICAL
LOGGING__FORMAT=json # json or text
LOGGING__FILE=/var/log/myapp/myapp.log # optional
LOGGING__BACKUP_COUNT=5 # rotated file generations
LOGGING__LEVELS='{"asyncssh":"WARNING"}' # per-logger overrides (JSON)
Or via CLI override:
CLI flags override environment variables and .env settings, following the
configuration hierarchy. --log-level sets the root level
only; per-logger overrides from logging.levels still apply.
Per-logger Levels¶
logging.levels maps logger names to levels. Use it to quiet a chatty library
(asyncssh, bleak, pymodbus, aiohttp, zeroconf) or to turn on DEBUG for one
module while the root stays at INFO:
- Level names are case-insensitive (
"warning"works) and must be one ofDEBUG,INFO,WARNING,ERROR,CRITICAL. Numeric levels are rejected. - Logger names are case-sensitive and kept exactly as written.
- An override applies to the named logger and its children (
asyncsshalso coversasyncssh.connection). A logger set toDEBUGemits debug records even when the root isINFO, because cosalette's handlers have no level of their own. - Each
configure_logging()call first resets the loggers the previous call overrode, so a removed override does not linger.
Seeing why a command or trigger payload was rejected
A rejected typed command or telemetry trigger payload logs one WARNING
with the sanitised summary (see
Error Handling). The
full traceback with the pydantic detail is logged at DEBUG. Turn it on
for the runner only, or use LOGGING__LEVEL=DEBUG for everything:
MYAPP_LOGGING__LEVELS='{"cosalette._runners._command_runner": "DEBUG", "cosalette._runners._telemetry_runner": "DEBUG"}'
The DEBUG record can contain the raw input values, so leave it off in
normal operation.
Prefer the JSON env form
pydantic-settings also accepts the nested form
MYAPP_LOGGING__LEVELS__asyncssh=WARNING, but it lowercases the key.
MYAPP_LOGGING__LEVELS__Foo=DEBUG configures the logger foo, not Foo.
Use the JSON form whenever a logger name contains uppercase letters.
App Defaults¶
To ship default overrides with your app, subclass LoggingSettings with a
levels default and annotate your settings' logging field with it:
from typing import Literal
from pydantic import Field
from pydantic_settings import SettingsConfigDict
import cosalette
Level = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
class MyLoggingSettings(cosalette.LoggingSettings):
levels: dict[str, Level] = Field(default={"asyncssh": "WARNING"})
class MySettings(cosalette.Settings):
model_config = SettingsConfigDict(env_prefix="MYAPP_", env_nested_delimiter="__")
logging: MyLoggingSettings = Field(default_factory=MyLoggingSettings)
pydantic copies the default for each instance, so the shared dict is safe.
MYAPP_LOGGING__LEVELS replaces the default mapping as a whole; other
MYAPP_LOGGING__* variables leave it in place.
Do not override the field's default instance
logging: LoggingSettings = LoggingSettings(levels={...}) looks equivalent
but is not: as soon as any MYAPP_LOGGING__* variable is set,
pydantic-settings builds logging from the environment alone and the
default instance, with its levels, is silently dropped. The subclass keeps
the default because it is part of the model, not of one instance.
See Also¶
- Configuration — LoggingSettings and the configuration hierarchy
- Error Handling — errors are logged at WARNING + published to MQTT
- Lifecycle — logging is configured in Phase 1 (Bootstrap)
- ADR-004 — Logging Strategy