feat(config): add JSON structured logging and syslog forwarding for application logs
- Add LOG_FORMAT setting (text/json) for structured JSON log output - Add LOG_SYSLOG_* settings for direct syslog forwarding of app logs - JSON format compatible with Grafana Loki, Splunk, ELK, Datadog - Syslog forwarding uses Python's SysLogHandler (UDP/TCP) - Update .env.demo and ConfigurationGuide.md with all new settings - Add tests for JSON formatter and syslog config fields Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -75,8 +75,6 @@ class TestEffectiveLogLevel:
|
||||
env = os.environ.copy()
|
||||
env.pop("LOG_LEVEL", None)
|
||||
with patch.dict(os.environ, env, clear=True):
|
||||
from app.config import Settings as S
|
||||
|
||||
s = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
redis_url="redis://localhost:6379",
|
||||
@@ -100,8 +98,6 @@ class TestEffectiveLogLevel:
|
||||
def test_explicit_log_level_overrides_debug(self):
|
||||
"""When LOG_LEVEL is explicitly set, it takes precedence over DEBUG=True."""
|
||||
with patch.dict(os.environ, {"LOG_LEVEL": "WARNING", "DEBUG": "true"}, clear=False):
|
||||
from app.config import Settings as S
|
||||
|
||||
s = Settings(
|
||||
database_url="sqlite:///test.db",
|
||||
redis_url="redis://localhost:6379",
|
||||
@@ -171,3 +167,119 @@ class TestLoggingConfiguredAtStartup:
|
||||
root = logging.getLogger()
|
||||
# The test env doesn't set DEBUG=True, so the level should be INFO (20)
|
||||
assert root.level <= logging.INFO
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestJsonFormatter:
|
||||
"""Tests for the _JsonFormatter used when LOG_FORMAT=json."""
|
||||
|
||||
def _make_formatter(self):
|
||||
"""Lazily import the JSON formatter from main module."""
|
||||
from app.main import _JsonFormatter
|
||||
|
||||
return _JsonFormatter()
|
||||
|
||||
def test_output_is_valid_json(self):
|
||||
"""JSON formatter output should be parseable JSON."""
|
||||
import json
|
||||
|
||||
fmt = self._make_formatter()
|
||||
record = logging.LogRecord(
|
||||
name="test.logger",
|
||||
level=logging.INFO,
|
||||
pathname="test.py",
|
||||
lineno=42,
|
||||
msg="Hello %s",
|
||||
args=("world",),
|
||||
exc_info=None,
|
||||
)
|
||||
result = fmt.format(record)
|
||||
parsed = json.loads(result)
|
||||
assert parsed["level"] == "INFO"
|
||||
assert parsed["logger"] == "test.logger"
|
||||
assert parsed["message"] == "Hello world"
|
||||
assert parsed["lineno"] == 42
|
||||
|
||||
def test_includes_timestamp_iso8601(self):
|
||||
"""JSON output should contain an ISO 8601 timestamp."""
|
||||
import json
|
||||
|
||||
fmt = self._make_formatter()
|
||||
record = logging.LogRecord(
|
||||
name="x",
|
||||
level=logging.DEBUG,
|
||||
pathname="x.py",
|
||||
lineno=1,
|
||||
msg="test",
|
||||
args=(),
|
||||
exc_info=None,
|
||||
)
|
||||
parsed = json.loads(fmt.format(record))
|
||||
assert "timestamp" in parsed
|
||||
# ISO 8601 timestamps contain "T" and "+00:00" (UTC)
|
||||
assert "T" in parsed["timestamp"]
|
||||
|
||||
def test_includes_exc_info_when_present(self):
|
||||
"""JSON output should include exc_info when an exception is logged."""
|
||||
import json
|
||||
|
||||
fmt = self._make_formatter()
|
||||
try:
|
||||
raise ValueError("boom") # noqa: TRY301
|
||||
except ValueError:
|
||||
import sys
|
||||
|
||||
record = logging.LogRecord(
|
||||
name="x",
|
||||
level=logging.ERROR,
|
||||
pathname="x.py",
|
||||
lineno=1,
|
||||
msg="error",
|
||||
args=(),
|
||||
exc_info=sys.exc_info(),
|
||||
)
|
||||
parsed = json.loads(fmt.format(record))
|
||||
assert "exc_info" in parsed
|
||||
assert "ValueError" in parsed["exc_info"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestLogFormatSetting:
|
||||
"""Tests for the log_format and log_syslog_* config fields."""
|
||||
|
||||
_BASE_KWARGS = {
|
||||
"database_url": "sqlite:///test.db",
|
||||
"redis_url": "redis://localhost:6379",
|
||||
"openai_api_key": "test",
|
||||
"azure_ai_key": "test",
|
||||
"azure_region": "test",
|
||||
"azure_endpoint": "https://test.example.com",
|
||||
"gotenberg_url": "http://localhost:3000",
|
||||
"workdir": "/tmp",
|
||||
"auth_enabled": False,
|
||||
"session_secret": None,
|
||||
}
|
||||
|
||||
def test_log_format_default_is_text(self):
|
||||
"""Test that log_format defaults to 'text'."""
|
||||
config = Settings(**self._BASE_KWARGS)
|
||||
assert config.log_format == "text"
|
||||
|
||||
def test_log_format_accepts_json(self):
|
||||
"""Test that log_format accepts 'json'."""
|
||||
config = Settings(**self._BASE_KWARGS, log_format="json")
|
||||
assert config.log_format == "json"
|
||||
|
||||
def test_log_syslog_defaults(self):
|
||||
"""Test syslog forwarding defaults."""
|
||||
config = Settings(**self._BASE_KWARGS)
|
||||
assert config.log_syslog_enabled is False
|
||||
assert config.log_syslog_host == "localhost"
|
||||
assert config.log_syslog_port == 514
|
||||
assert config.log_syslog_protocol == "udp"
|
||||
|
||||
def test_log_syslog_can_be_enabled(self):
|
||||
"""Test that syslog forwarding can be enabled."""
|
||||
config = Settings(**self._BASE_KWARGS, log_syslog_enabled=True, log_syslog_host="syslog.example.com")
|
||||
assert config.log_syslog_enabled is True
|
||||
assert config.log_syslog_host == "syslog.example.com"
|
||||
|
||||
Reference in New Issue
Block a user