diff options
| author | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 16:06:46 +0900 |
|---|---|---|
| committer | TheSiahxyz <164138827+TheSiahxyz@users.noreply.github.com> | 2026-04-01 16:06:46 +0900 |
| commit | cd15c3f64d00c6c97f738d59f719bb0938d9f7cb (patch) | |
| tree | 3f4fcadf625a0a19f9ffd4f676b4e22e83a261af /shared | |
| parent | c1d53dbc173f87fe23e179f21c9a713df6484dae (diff) | |
feat(shared): add structlog-based structured logging
Diffstat (limited to 'shared')
| -rw-r--r-- | shared/src/shared/logging.py | 70 | ||||
| -rw-r--r-- | shared/tests/test_logging.py | 106 |
2 files changed, 176 insertions, 0 deletions
diff --git a/shared/src/shared/logging.py b/shared/src/shared/logging.py new file mode 100644 index 0000000..b873eaf --- /dev/null +++ b/shared/src/shared/logging.py @@ -0,0 +1,70 @@ +"""Structured logging configuration using structlog.""" +from __future__ import annotations + +import logging +import sys + +import structlog + + +def setup_logging( + service_name: str, + log_level: str = "INFO", + log_format: str = "json", +) -> structlog.stdlib.BoundLogger: + """Configure structlog and stdlib logging, return a bound logger. + + Args: + service_name: Identifier bound as ``service`` context variable. + log_level: Root logger level (e.g. "DEBUG", "INFO", "WARNING"). + log_format: "json" for machine-readable output, "console" for + human-readable coloured output. + + Returns: + A structlog ``BoundLogger`` with ``service=service_name`` pre-bound. + """ + # Shared processors used by both structlog and stdlib formatter + shared_processors: list[structlog.types.Processor] = [ + structlog.contextvars.merge_contextvars, + structlog.stdlib.add_log_level, + structlog.stdlib.add_logger_name, + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.UnicodeDecoder(), + ] + + # Choose renderer based on format + if log_format == "json": + renderer: structlog.types.Processor = structlog.processors.JSONRenderer() + else: + renderer = structlog.dev.ConsoleRenderer() + + # Configure structlog + structlog.configure( + processors=[ + *shared_processors, + structlog.stdlib.ProcessorFormatter.wrap_for_formatter, + ], + logger_factory=structlog.stdlib.LoggerFactory(), + wrapper_class=structlog.stdlib.BoundLogger, + cache_logger_on_first_use=False, + ) + + # Configure stdlib root logger + formatter = structlog.stdlib.ProcessorFormatter( + processors=[ + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + renderer, + ], + ) + + handler = logging.StreamHandler(sys.stderr) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.handlers.clear() + root_logger.addHandler(handler) + root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO)) + + # Return a logger with service name pre-bound + return structlog.get_logger(service=service_name) diff --git a/shared/tests/test_logging.py b/shared/tests/test_logging.py new file mode 100644 index 0000000..4abd254 --- /dev/null +++ b/shared/tests/test_logging.py @@ -0,0 +1,106 @@ +"""Tests for shared structured logging module.""" +import io +import json +import logging + +import pytest +import structlog + + +@pytest.fixture(autouse=True) +def _reset_structlog(): + """Reset structlog configuration between tests.""" + structlog.reset_defaults() + # Remove all handlers from root logger to avoid cross-test leakage + root = logging.getLogger() + root.handlers.clear() + yield + structlog.reset_defaults() + root.handlers.clear() + + +def test_setup_logging_returns_logger(): + """setup_logging should return a bound structlog logger.""" + from shared.logging import setup_logging + + logger = setup_logging(service_name="test-svc") + assert logger is not None + # Should be usable as a logger (has info, debug, etc.) + assert callable(getattr(logger, "info", None)) + assert callable(getattr(logger, "error", None)) + + +def test_service_name_bound_in_context(): + """The returned logger should have service=service_name bound.""" + from shared.logging import setup_logging + + logger = setup_logging(service_name="my-service", log_format="json") + + # Capture output via a stream handler on the root logger + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setLevel(logging.DEBUG) + # Use the same formatter structlog sets up + root = logging.getLogger() + handler.setFormatter(root.handlers[0].formatter) + root.addHandler(handler) + + logger.info("hello") + + output = stream.getvalue() + parsed = json.loads(output) + assert parsed["service"] == "my-service" + + +def test_log_level_set_correctly(): + """setup_logging should set the stdlib root logger level.""" + from shared.logging import setup_logging + + setup_logging(service_name="test", log_level="WARNING") + root = logging.getLogger() + assert root.level == logging.WARNING + + +def test_json_format_produces_json(): + """log_format='json' should produce JSON output.""" + from shared.logging import setup_logging + + logger = setup_logging(service_name="json-svc", log_format="json") + + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setLevel(logging.DEBUG) + root = logging.getLogger() + handler.setFormatter(root.handlers[0].formatter) + root.addHandler(handler) + + logger.info("test-json") + + output = stream.getvalue().strip() + parsed = json.loads(output) + assert "event" in parsed + assert parsed["event"] == "test-json" + assert "timestamp" in parsed + assert "level" in parsed + + +def test_console_format_produces_output(): + """log_format='console' should produce human-readable output (not JSON).""" + from shared.logging import setup_logging + + logger = setup_logging(service_name="console-svc", log_format="console") + + stream = io.StringIO() + handler = logging.StreamHandler(stream) + handler.setLevel(logging.DEBUG) + root = logging.getLogger() + handler.setFormatter(root.handlers[0].formatter) + root.addHandler(handler) + + logger.info("test-console") + + output = stream.getvalue().strip() + assert len(output) > 0 + # Console output should NOT be valid JSON + with pytest.raises(json.JSONDecodeError): + json.loads(output) |
