Testing Logging Configuration in CI
A logging configuration is code that runs exactly once per process, at startup, and in most services it is only ever run in full in production. That makes it an unusually efficient source of deploy failures: a mistyped class path, a file handler pointed at a directory the container does not have, a formatter whose dependency is missing from the image. This page covers loading every environment's configuration in the test suite, asserting that the resulting logger tree is right, and a smoke test in the built image for the failures only the image can produce. It is a task article under log testing and verification, part of the Python logging fundamentals and structured data section, and it assumes the configuration is expressed as described in configuring logging with dictConfig.
Prerequisites
pip install "pytest>=8.0.0,<9.0.0" \
"python-json-logger>=2.0.7,<4.0.0"
Implementation
Step 1 — Build the configuration from a function that takes the environment. A configuration file per environment tends to drift, and the variant nobody loads locally is the one that breaks. A function that returns a dictConfig dictionary given an environment name makes every variant constructible and therefore testable, and it keeps the differences between environments explicit in one place.
# myservice/logging_config.py
import os
def build_config(environment: str, queue: bool = True) -> dict:
level = {"production": "INFO", "staging": "INFO"}.get(environment, "DEBUG")
handlers = {
"stdout": {
"class": "logging.StreamHandler",
"stream": "ext://sys.stdout",
"formatter": "json" if environment != "local" else "plain",
},
}
if environment == "production":
handlers["audit"] = {
"class": "logging.handlers.RotatingFileHandler",
"filename": os.path.join(os.environ["LOG_DIR"], "audit.jsonl"),
"maxBytes": 64 * 1024 * 1024,
"backupCount": 5,
"formatter": "json",
}
return {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json": {"()": "pythonjsonlogger.jsonlogger.JsonFormatter",
"fmt": "%(asctime)s %(levelname)s %(name)s %(message)s"},
"plain": {"format": "%(levelname)-8s %(name)s: %(message)s"},
},
"handlers": handlers,
"loggers": {
"urllib3": {"level": "WARNING"},
"botocore": {"level": "WARNING"},
"audit": {"handlers": ["audit"] if "audit" in handlers else [],
"level": "INFO", "propagate": environment != "production"},
},
"root": {"level": level, "handlers": ["stdout"]},
}
Step 2 — Load every variant in a test. Parametrising over environments means each one is applied with dictConfig in the test suite. Any configuration that cannot load — an unimportable class, a formatter key referencing a formatter that does not exist, a missing environment variable — fails here rather than at startup.
import logging
import logging.config
import pytest
from myservice.logging_config import build_config
@pytest.mark.parametrize("environment", ["local", "staging", "production"])
def test_config_loads(environment, tmp_path, monkeypatch):
monkeypatch.setenv("LOG_DIR", str(tmp_path))
logging.config.dictConfig(build_config(environment))
Step 3 — Assert on the resulting logger tree. Loading proves the configuration is valid; it does not prove it is right. A small set of assertions on the loggers that matter — the root level, the handler types, the level of chatty libraries, the propagation of special loggers — catches the configuration that loads and does the wrong thing.
def test_production_tree_is_as_intended(tmp_path, monkeypatch):
monkeypatch.setenv("LOG_DIR", str(tmp_path))
logging.config.dictConfig(build_config("production"))
root = logging.getLogger()
assert root.level == logging.INFO, "production must not log DEBUG"
assert {type(h).__name__ for h in root.handlers} == {"StreamHandler"}
for noisy in ("urllib3", "botocore"):
assert logging.getLogger(noisy).level >= logging.WARNING, f"{noisy} will flood"
audit = logging.getLogger("audit")
assert audit.propagate is False, "audit records must not also go to stdout"
assert any(type(h).__name__ == "RotatingFileHandler" for h in audit.handlers)
Expected Output: a failure that explains itself when somebody sets the production root to debug for an investigation and forgets to revert it.
FAILED test_logging_config.py::test_production_tree_is_as_intended
AssertionError: production must not log DEBUG
Step 4 — Smoke test the built image. The unit tests run against the development environment's installed packages and filesystem. The image has its own. A few seconds of running the real container with its production configuration, emitting a record and validating the output catches failures that exist only there.
#!/bin/sh
# ci/log-smoke.sh — run the built image, emit one record, validate it
set -e
IMAGE="$1"
OUTPUT=$(docker run --rm -e ENVIRONMENT=production -e LOG_DIR=/var/log/app \
"$IMAGE" python -c "
import logging, logging.config
from myservice.logging_config import build_config
logging.config.dictConfig(build_config('production'))
logging.getLogger('smoke').info('log smoke test', extra={'probe': 'ok'})
" 2>&1)
echo "$OUTPUT" | python3 -c '
import json, sys
lines = [l for l in sys.stdin.read().splitlines() if l.strip()]
assert len(lines) == 1, f"expected one line, got {len(lines)}: {lines}"
rec = json.loads(lines[0])
assert rec["probe"] == "ok" and rec["levelname"] == "INFO", rec
print("log smoke: ok")
'
Expected Output: one line of valid JSON, or a failure naming what the image lacks.
log smoke: ok
FileNotFoundError: [Errno 2] No such file or directory: '/var/log/app/audit.jsonl'
Step 5 — Check library logger levels explicitly. A configuration refactor that drops the entries quietening noisy dependencies loads cleanly, passes a load test, and floods production with debug output from HTTP and cloud client libraries. An explicit assertion for each such library — or a loop over a maintained list — catches it. The background on which libraries are noisy and why is in taming third-party library loggers.
Why configuration fails where it does
The failures this page targets have a common cause: the configuration's behaviour depends on its environment, and the one environment where it has never been exercised is the one that matters.
Import paths differ. A dictConfig references handler and formatter classes by dotted path, resolved by importing at load time. A path that resolves in a development checkout — because the package is installed in editable mode, or a module is on the path by accident — may not resolve in an image built from a wheel. The failure is an import error at startup, and nothing else in the service would have exercised that import.
Filesystems differ. File handlers open their files at configuration time, not at first write. A path that exists on every developer machine and in the test environment may not exist in a minimal container image, or may exist and be read-only. The handler raises during dictConfig, before the application has logged anything.
Dependencies differ. A formatter provided by a package that is a transitive dependency in development but not listed directly may be absent from a production image built from a stricter lockfile. The configuration references it; the import fails.
Environment variables differ. A configuration that reads a directory, a level or a feature flag from the environment behaves differently wherever the variable differs. A missing variable is sometimes a crash and sometimes, worse, a silent fallback to a development default — debug level in production.
In every case the configuration is correct in the abstract and wrong in one environment. That is why loading it in tests is necessary but not sufficient, and why the image smoke test is worth the few seconds it costs.
What to do when the configuration fails at startup anyway
No amount of testing makes startup failure impossible, and a logging configuration that fails is an unusually awkward failure, because the thing that would report the problem is the thing that is broken. Two defensive measures make the failure legible when it happens.
The first is a fallback. Wrapping the dictConfig call so that an exception installs a minimal configuration — a plain stream handler on standard error at warning level — and then logs the original exception means the service at least reports why its logging is degraded, in a form the container runtime will capture. Whether the service should then continue or exit is a policy decision; the important property is that the failure is visible either way.
The second is to fail loudly and early rather than partially. A configuration that loads some handlers and not others — possible when a handler's construction fails after others have been set up — produces a service that logs to some destinations and silently not to others. Treating any configuration error as fatal, after emitting the fallback message, is preferable to running with a half-configured logging tree that nobody knows about.
import logging, logging.config, sys
def configure(environment: str) -> None:
try:
logging.config.dictConfig(build_config(environment))
except Exception:
logging.basicConfig(level=logging.WARNING, stream=sys.stderr, force=True)
logging.getLogger("startup").exception("logging configuration failed")
raise SystemExit(78) # configuration error: fail the deploy visibly
Configuration options
| Check | Stage | Catches | Cost |
|---|---|---|---|
| Load each variant | unit tests | invalid configuration | milliseconds |
| Root level per environment | unit tests | debug in production | milliseconds |
| Handler types | unit tests | synchronous handlers where a queue was intended | milliseconds |
| Library levels | unit tests | noisy dependencies re-enabled | milliseconds |
| Propagation of special loggers | unit tests | audit records duplicated or lost | milliseconds |
| Image smoke test | pipeline | missing packages, paths, variables | one container start |
Verification
Confirm the suite catches a deliberately broken configuration before trusting it.
# introduce a typo in a handler class and run only the config tests
sed -i 's/logging.StreamHandler/logging.StreamHandlr/' myservice/logging_config.py
pytest tests/test_logging_config.py -q; git checkout myservice/logging_config.py
Expected Output: every variant failing to load, naming the bad class.
FFF
ValueError: Unable to configure handler 'stdout'
Common mistakes
Only production loads the production configuration. Error signature: deploys failing at startup on configuration errors. Root cause: the variant is never exercised elsewhere. Remediation: build every variant from a function and load each in a test.
Load tests with no assertions. Error signature: a configuration that loads and logs at debug level in production. Root cause: loading proves validity, not correctness. Remediation: assert on the resulting tree.
File handlers pointed at development paths. Error signature: FileNotFoundError during dictConfig in the container. Root cause: the path exists locally and not in the image. Remediation: take paths from the environment, and smoke test the image.
A silent fallback for a missing variable. Error signature: production running at a development log level with no error. Root cause: a default applied when the environment variable is absent. Remediation: require the variable in production, and assert the level in tests.
Tests that leave logging configured. Error signature: unrelated tests changing behaviour depending on order. Root cause: dictConfig modifies global state. Remediation: reset logging after each configuration test.
Frequently Asked Questions
Why does a logging configuration fail only in production?
Because it is only ever loaded there. A dictConfig referencing a handler class that is imported differently in the production image, a file handler pointed at a directory that exists on developer machines but not in the container, or a formatter depending on a package missing from the lockfile all fail at startup, and startup in production is the first time they run.
Is loading the configuration in a test enough?
It catches configurations that fail to load. It does not catch configurations that load and are wrong — a handler attached to the wrong logger, a level set to DEBUG, propagation disabled where it should not be. Assertions on the resulting tree catch those.
What does an image smoke test add?
It runs in the environment that will actually run, with the dependencies and filesystem the image actually has. Failures caused by the image rather than by the code — a missing package, a read-only path, an absent environment variable — only appear there.
Should the configuration live in YAML or in code?
Either works, but building it with a function that takes the environment as a parameter makes every variant testable. A YAML file per environment tends to drift, and the variant nobody loads locally is the one that breaks.