Files
hold-slayer/tests/test_logging_config.py
Robert Helewka 1644999bcb feat(logging): structured JSON logs, uvicorn access log included
Hold Slayer's logs are shipped to Loki by the host's Alloy agent, which
reads container stdout. Text lines arrive there as an opaque blob:
filtering on a status code meant regex over a formatted string. This adds
LOG_FORMAT=json (default "text", so local dev stays readable) rendering
one JSON object per line.

Two parts were less obvious than a format= argument would suggest, and
both are why this is a module rather than a basicConfig tweak:

Uvicorn attaches its own handlers to `uvicorn` and `uvicorn.access` with
propagate=False, so configuring only the root logger would have left the
access log — the highest-volume, most useful stream — as colourised text
next to our JSON. configure_logging clears those handlers and re-enables
propagation, and is called both at import (for startup config checks) and
in lifespan (uvicorn configures itself after importing the app). The
__main__ path passes log_config=None so uvicorn never applies its own.

The access record's payload lives in record.args as a 5-tuple, not in the
message. Formatting it would throw the structure away and force Loki to
parse it back out, so the tuple is unpacked into real fields and
status_code is emitted as a number for range filtering.

Also drops uvicorn's `color_message` extra, an ANSI-coloured duplicate of
the message that generic extra-promotion would otherwise copy into every
startup line — the same unreadable-in-Grafana problem recently fixed for
the lab's Asterisk logs.

Verified against a real uvicorn server: 39/39 lines valid JSON, zero ANSI
escapes, no duplicates, access lines structured with correct status codes;
text mode unchanged. Thread name is included off the main thread, since
"which execution context logged this" is the first question when debugging
across the asyncio/Sippy/PJSUA2 boundary. SecretStr extras stay masked.

README Phase 4 item ticked; LOG_FORMAT and the previously-undocumented
LOG_LEVEL added to the config table and .env.example.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-30 06:18:06 -04:00

184 lines
6.6 KiB
Python

"""
Structured-logging tests.
The two things worth guarding are the ones that are easy to break silently:
uvicorn's access logger must actually route through our formatter (it sets
`propagate = False` and brings its own handler), and its arg tuple must land as
real JSON fields rather than a pre-formatted string.
"""
import json
import logging
import logging.config
import pytest
from config import Settings
from core.logging_config import JSONFormatter, configure_logging
def _record(name="test.logger", level=logging.INFO, msg="hello", args=None, **extra):
record = logging.LogRecord(
name=name, level=level, pathname=__file__, lineno=1, msg=msg, args=args, exc_info=None
)
for key, value in extra.items():
setattr(record, key, value)
return record
def _emit(record):
return json.loads(JSONFormatter().format(record))
class TestJSONFormatter:
def test_emits_single_line_json_with_core_fields(self):
out = JSONFormatter().format(_record())
assert "\n" not in out
payload = json.loads(out)
assert payload["msg"] == "hello"
assert payload["level"] == "INFO"
assert payload["logger"] == "test.logger"
def test_timestamp_is_utc_rfc3339_with_date(self):
# logging's default asctime is local-time and date-less, which is
# exactly what makes text logs hard to correlate in Loki.
ts = _emit(_record())["ts"]
assert ts.endswith("+00:00")
assert "T" in ts
def test_interpolates_message_args(self):
assert _emit(_record(msg="call %s ended", args=("abc123",)))["msg"] == "call abc123 ended"
def test_extra_fields_are_promoted(self):
payload = _emit(_record(call_id="c-1", duration=12.5))
assert payload["call_id"] == "c-1"
assert payload["duration"] == 12.5
def test_uvicorn_color_message_is_dropped(self):
# Uvicorn ships an ANSI-coloured copy of the message via extra=; letting
# it through puts escape codes in Loki.
payload = _emit(
_record(name="uvicorn.error", msg="Started", color_message="\x1b[36mStarted\x1b[0m")
)
assert "color_message" not in payload
assert "\x1b" not in json.dumps(payload)
def test_non_json_native_extra_is_stringified(self):
payload = _emit(_record(obj=object()))
assert isinstance(payload["obj"], str)
def test_secretstr_extra_stays_masked(self):
# Config secrets are SecretStr precisely so an accidental log can't leak
# them; stringification must preserve that.
from pydantic import SecretStr
payload = _emit(_record(secret=SecretStr("hs_pat_supersecret")))
assert "supersecret" not in json.dumps(payload)
def test_exception_is_captured(self):
try:
raise ValueError("boom")
except ValueError:
import sys
record = _record(level=logging.ERROR, msg="failed")
record.exc_info = sys.exc_info()
payload = _emit(record)
assert "ValueError: boom" in payload["exc"]
def test_thread_name_included_only_off_main_thread(self):
# Which execution context logged a line is the first question when
# debugging a call across the asyncio/Sippy/PJSUA2 boundary.
on_main = _record()
on_main.threadName = "MainThread"
assert "thread" not in _emit(on_main)
off_main = _record()
off_main.threadName = "sippy-ed"
assert _emit(off_main)["thread"] == "sippy-ed"
class TestAccessLogFields:
def _access(self, status=200):
return _emit(
_record(
name="uvicorn.access",
msg='%s - "%s %s HTTP/%s" %d',
args=("127.0.0.1:5050", "GET", "/api/v1/calls", "1.1", status),
)
)
def test_arg_tuple_becomes_structured_fields(self):
payload = self._access()
assert payload["method"] == "GET"
assert payload["path"] == "/api/v1/calls"
assert payload["client_addr"] == "127.0.0.1:5050"
assert payload["http_version"] == "1.1"
def test_status_code_is_a_number_not_a_string(self):
# So Loki can range-filter on it (status_code >= 500).
assert self._access(503)["status_code"] == 503
def test_access_line_is_not_pre_formatted(self):
# The whole point: no interpolated request line to regex back apart.
assert "msg" not in self._access()
def test_unexpected_arg_shape_falls_back_to_message(self):
# A logging path that raises would take out the request; degrade instead.
payload = _emit(_record(name="uvicorn.access", msg="just a string", args=None))
assert payload["msg"] == "just a string"
class TestConfigureLogging:
@pytest.fixture(autouse=True)
def _restore(self):
root = logging.getLogger()
saved = root.handlers[:], root.level
yield
root.handlers[:] = saved[0]
root.setLevel(saved[1])
def _uvicorn_defaults(self):
"""Reproduce what uvicorn does to its loggers at startup."""
from uvicorn.config import LOGGING_CONFIG
logging.config.dictConfig(LOGGING_CONFIG)
def test_takes_over_uvicorn_handlers(self):
self._uvicorn_defaults()
access = logging.getLogger("uvicorn.access")
assert access.propagate is False # uvicorn's default, the problem
configure_logging("json", "info")
assert access.propagate is True
assert access.handlers == []
def test_json_mode_installs_json_formatter(self):
configure_logging("json", "info")
assert isinstance(logging.getLogger().handlers[0].formatter, JSONFormatter)
def test_text_mode_does_not(self):
configure_logging("text", "info")
assert not isinstance(logging.getLogger().handlers[0].formatter, JSONFormatter)
def test_is_idempotent(self):
# Called at import and again in lifespan; a second call must replace the
# handler, not add one, or every line is emitted twice.
configure_logging("json", "info")
configure_logging("json", "info")
assert len(logging.getLogger().handlers) == 1
def test_respects_log_level(self):
configure_logging("json", "warning")
assert logging.getLogger().level == logging.WARNING
class TestSettings:
def test_defaults_to_text(self):
# A JSON-only default would make local development worse.
assert Settings(database_url="sqlite+aiosqlite:///:memory:").log_format == "text"
def test_reads_log_format_env(self, monkeypatch):
monkeypatch.setenv("LOG_FORMAT", "json")
assert Settings(database_url="sqlite+aiosqlite:///:memory:").log_format == "json"