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>
184 lines
6.6 KiB
Python
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"
|