Logging Convention#
Every service in the Data Transfer System emits structured log lines in logfmt format. This document describes the format, the level policy, the runtime level switch, the canonical field dictionary, and the developer API.
How to Log (Developer Quickstart)#
from ccat_data_transfer.logging_utils import get_structured_logger
logger = get_structured_logger(__name__)
# INFO — one line per work-unit stage
logger.info("transfer started", transfer_id=42, location="cologne", size_bytes=1073741824)
# ERROR — failed operation, with automatic traceback capture
try:
do_archive(archive_id)
except Exception as exc:
logger.error("archive failed", error=exc, archive_id=7)
# or, equivalently, from inside an except block:
except Exception:
logger.exception("archive failed", archive_id=7)
get_structured_logger(__name__) returns a StructuredLogger wrapping the
standard library logger hierarchy. The handler is installed once on the
ccat_data_transfer base logger; module-level loggers propagate to it
automatically — no per-module handler needed.
Log Line Format#
Every line is a single valid logfmt record. The LogfmtFormatter handler
prepends the three fixed prefix fields exactly once per line:
ts=<utc-iso8601> level=<lowercase> logger=<short-module> msg="<phrase>" <key=value ...>
Field |
Source |
Notes |
|---|---|---|
|
emit time |
UTC ISO-8601, e.g. |
|
record level |
lowercase: |
|
|
|
|
first positional arg |
always quoted (messages contain spaces) |
remaining |
kwargs |
quoted when the value contains a space, |
Worked Example — Normal INFO Line#
ts=2026-06-20T07:30:33.842106+00:00 level=info logger=transfer_manager msg="transfer succeeded" transfer_id=42 location=cologne size_bytes=1024
Before / After Contrast#
The superseded style (no longer emitted) mixed a redundant level prefix into the message text and carried additional metadata in an ad-hoc tail:
# BEFORE — superseded, do not write
INFO - msg="transfer succeeded" transfer_id=42 lvl=INFO t=2026-06-20T07:30:33.842106+00:00 logger=ccat_data_transfer.transfer_manager
The leading INFO - prefix made the line invalid logfmt from the first
character. The trailing lvl= field printed the logger’s effective
threshold (e.g. INFO), not the severity of the individual message — so a
DEBUG message emitted while the logger sat at INFO level was still printed
with lvl=INFO. The new format is clean logfmt from the first character, with
no embedded level prefix and no duplicate lvl=/t=/logger= tail.
Traceback Lines#
Tracebacks are appended on the lines immediately following the summary logfmt
line, without a ts= prefix. This intentional absence lets log collectors that
key on ^ts= fold them into the same log entry:
ts=2026-06-20T07:35:29.724966+00:00 level=error logger=archive_manager msg="archive failed" archive_id=7 error_type=ValueError error="disk full at /mnt/archive"
Traceback (most recent call last):
...
ValueError: disk full at /mnt/archive
The error_type and error fields on the summary line give a machine-readable
one-liner for alerting. The full traceback on the following lines is available
for deep debugging. Never embed traceback text inside a quoted field value —
that breaks logfmt parsing and multiline folding.
Level Policy#
Standing production level is INFO. Levels and their intended meanings:
Level |
Meaning |
|---|---|
|
Per-item enumeration, internal state traces, verbose path details. Off in normal production. |
|
One line per work-unit stage: lifecycle events, state transitions, actionable milestones. |
|
Unexpected condition that was handled automatically — the system kept working. |
|
A specific operation failed and needs a human to investigate or intervene. |
|
A manager has hit the consecutive-error threshold; service health is in question. |
The Expected-Absence Rule#
A normal or expected absence — “no pending packages found”, “source file not
yet arrived”, “nothing to do this cycle” — is not a warning. Log it at
DEBUG or INFO. Reserve WARNING for something that was genuinely
unexpected but did not require stopping.
Example:
# Correct: absence is expected during normal operation
if not pending:
logger.debug("no pending transfers", location=location)
return
# Incorrect: would produce spurious warnings every poll cycle
if not pending:
logger.warning("no transfers found", location=location)
Runtime LOG_LEVEL Switch#
The active log level is controlled by the LOG_LEVEL operational-config key.
The default value is INFO, defined in settings.toml and active in all
non-development environments:
LOG_LEVEL = "INFO" # [default] section
LOG_LEVEL = "DEBUG" # [development] and [localdev] sections
Allowed values: DEBUG, INFO, WARNING, ERROR.
How the Switch Works#
LOG_LEVEL is stored in the SystemSettings table and is editable through
the AdminSettings UI or the operational-config API — both maintain an audit
trail of every change.
Manager poll loops call
apply_runtime_log_level(op_config, session)at the end of every poll cycle (afterwork(), before the inter-cycle sleep). TheOperationalConfigcache refreshes every 60 seconds, so a level change takes effect within one cache window plus one poll cycle — no redeploy or restart required.Celery workers call
apply_runtime_log_levelat the start of every task execution, using the same 60-second-cachedOperationalConfig. A level change reaches workers within one cache window.
The practical result: an incident responder can set LOG_LEVEL=DEBUG, watch
verbose output stream from a live service, then set it back to INFO — all
without touching the process.
configure_logging() is idempotent: if a LogfmtFormatter handler is already
attached to the base logger, only the level is updated. Multiple calls are
safe.
Message Phrasing Rules#
Lowercase stable phrases — the message text must not change between invocations for the same event. It is the primary grouping key in log aggregators.
No interpolation in the message — data belongs in keyword arguments, never in the message string.
Past tense for terminal events —
"transfer succeeded","transfer failed","package created".Acronyms and identifiers keep their casing — BBCP, S3, LTA, DB, Redis.
# Correct
logger.info("transfer succeeded", transfer_id=42, duration_s=12.4)
logger.error("S3 upload failed", error=exc, archive_id=7)
# Incorrect — data in message text; fails ruff G004
logger.info(f"transfer {transfer_id} succeeded in {duration:.1f}s")
Error and Traceback Handling#
Use .error("phrase", error=exc) when you have the exception object at hand.
Use .exception("phrase") from inside an except block when the current
exception is implicit.
Both methods:
Extract
error_type=<ExceptionClass>anderror="<single-line message>"onto the summary logfmt line.Carry the full traceback via
exc_infosoLogfmtFormatterappends it on the following lines — not embedded inside a quoted value.
For CCATDataOperationError subclasses, transfer_id is also extracted
automatically.
# Preferred: pass the exception object
try:
run_transfer(transfer_id)
except Exception as exc:
logger.error("transfer failed", error=exc, transfer_id=transfer_id)
# Also correct: inside an except block, exception is implicit
try:
run_transfer(transfer_id)
except Exception:
logger.exception("transfer failed", transfer_id=transfer_id)
# Banned: f-string interpolation of exception text
except Exception as e:
logger.error(f"transfer failed: {str(e)}") # fails ruff G004
Field Dictionary#
Use these canonical snake_case keys whenever logging the corresponding concept. Keys align with ops-db entity names so log lines are directly correlatable with database records.
Concept |
Key |
|---|---|
Site |
|
Data location |
|
Raw data package |
|
Transfer package |
|
Data transfer |
|
LTA transfer |
|
File |
|
Physical copy |
|
Staging job |
|
Archive |
|
Operation type |
|
Celery |
|
Size |
|
Counts |
|
Duration |
|
Retries |
|
Status |
|
Error |
|
Log Collection: Loki and Grafana#
Promtail (the log collector, configured in the system-integration repo) reads the logfmt output and:
Promotes
levelto a low-cardinality Loki label — enabling label-based filtering such as{job="transfer-manager"} | level="error".Adopts
tsas the Loki entry timestamp, so the stored timestamp reflects when the event occurred, not when the collector saw the line.Folds tracebacks using a multiline rule keyed on
^ts=: continuation lines (nots=prefix) are merged into the preceding entry so the complete traceback appears as one Loki log event.
Do not promote high-cardinality fields such as transfer_id, archive_id,
or task_id to Loki labels — they create unbounded label cardinalities and
degrade query performance. Query them instead with the logfmt filter:
{job="transfer-manager"} | logfmt | transfer_id="42"
The Promtail pipeline configuration and Grafana dashboard definitions live in the system-integration repository.
Ruff G + LOG Guardrail#
pyproject.toml enforces the structured logging convention across the entire
package:
[tool.ruff.lint]
extend-select = ["G", "LOG"]
The G ruleset catches f-string log calls (G004), %-format log calls, and
string concatenation in log arguments. The LOG ruleset catches misuse of the
logging module itself (e.g. calling logger.warn instead of
logger.warning).
ruff check . will fail on any new logger.info(f"...") in the package source.
The tests/ and examples/ directories are excluded — they are out of scope
for the production log-format convention.
When writing a new log call, always use the keyword-argument form:
# This passes ruff
logger.info("package created", raw_package_id=pkg.id, file_count=n)
# This fails ruff G004
logger.info(f"package {pkg.id} created with {n} files")