summaryrefslogtreecommitdiff
path: root/shared
diff options
context:
space:
mode:
Diffstat (limited to 'shared')
-rw-r--r--shared/src/shared/logging.py70
-rw-r--r--shared/tests/test_logging.py106
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)